diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index 871dbf5d6..82bb44725 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -122,23 +122,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "d84433a6faaf6bafb0cb44e6d35431c375b15529fce32873a5b265a0a6e5c134", - "end_line": 1561, + "content_sha256": "5d706a12456adbef037616d73215b361450d24ce5f49cd68dcebb4cb66dbbb23", + "end_line": 5820, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1561, - "path": "backend/tests/test_default_pre_submit_execution.py", - "qualified_symbol": null, - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1 - }, - { - "capability": "oversized_authorization_structure", - "content_sha256": "f3098d904831d566b1fef38c6d8bda4d17765958ed899f8c6b219afa12cc6c0c", - "end_line": 5821, - "hard_limit": 1200, - "kind": "test_file", - "observed_lines": 5821, + "observed_lines": 5820, "path": "backend/tests/test_projects.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -396,18 +384,6 @@ "removal_chunk": "WS-AUTH-003-CLOSE", "start_line": 10047 }, - { - "capability": "oversized_authorization_structure", - "content_sha256": "c8f454738457615f8e183744c677e8384ef01c27f5dac2b352caf84f4c77b2d1", - "end_line": 931, - "hard_limit": 120, - "kind": "test_function", - "observed_lines": 487, - "path": "backend/tests/test_default_pre_submit_execution.py", - "qualified_symbol": "test_effective_evidence_workflow_persists_once_and_replays_exactly", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 445 - }, { "capability": "oversized_authorization_structure", "content_sha256": "26170dfbd986d2930d7f89357d59cdd17ae50b2ac1b936a3966e24225312e895", @@ -423,26 +399,26 @@ { "capability": "oversized_authorization_structure", "content_sha256": "e723bd10cd20ba716fb75edda2a5b0c21a78dfce771efd55154834063c08b4b4", - "end_line": 2211, + "end_line": 2212, "hard_limit": 120, "kind": "test_function", "observed_lines": 128, "path": "backend/tests/test_projects.py", "qualified_symbol": "test_guide_mutation_service_executes_all_three_authorized_happy_paths", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2084 + "start_line": 2085 }, { "capability": "oversized_authorization_structure", "content_sha256": "a801cae6e9f200073a42b7a2cb94edd614480899288fbe78783ea78fffe859c7", - "end_line": 1035, + "end_line": 1036, "hard_limit": 120, "kind": "test_function", "observed_lines": 137, "path": "backend/tests/test_projects.py", "qualified_symbol": "test_project_role_grant_repository_filters_and_uses_strict_keyset", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 899 + "start_line": 900 }, { "capability": "oversized_authorization_structure", diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 2418a9356..8add70ea2 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -378,27 +378,27 @@ }, { "group": "artifacts", - "target": "backend/app/modules/artifacts/pre_submit_attempts.py" + "target": "backend/app/modules/artifacts/preparation.py" }, { "group": "artifacts", - "target": "backend/app/modules/artifacts/pre_submit_evidence.py" + "target": "backend/app/modules/artifacts/repository.py" }, { "group": "artifacts", - "target": "backend/app/modules/artifacts/preparation.py" + "target": "backend/app/modules/artifacts/router.py" }, { "group": "artifacts", - "target": "backend/app/modules/artifacts/repository.py" + "target": "backend/app/modules/artifacts/schemas.py" }, { "group": "artifacts", - "target": "backend/app/modules/artifacts/router.py" + "target": "backend/app/modules/artifacts/selected_pre_submit_admission.py" }, { "group": "artifacts", - "target": "backend/app/modules/artifacts/schemas.py" + "target": "backend/app/modules/artifacts/selected_pre_submit_execution.py" }, { "group": "artifacts", @@ -660,6 +660,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/post_submit_routing.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/pre_submit.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/prepared_adapter_bindings.py" @@ -780,6 +784,10 @@ "group": "auth", "target": "backend/app/modules/authorization/post_submit_routing_authorization.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/pre_submit_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/pre_submit_materialization.py" @@ -896,6 +904,18 @@ "group": "artifacts", "target": "backend/app/modules/checkers/api/artifact_paths.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/api/canonical_values.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/api/default_checker.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/api/evaluation_contract.py" + }, { "group": "artifacts", "target": "backend/app/modules/checkers/api/execution.py" @@ -906,39 +926,39 @@ }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/history.py" + "target": "backend/app/modules/checkers/api/external_policy.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/materialization.py" + "target": "backend/app/modules/checkers/api/history.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/output_custody.py" + "target": "backend/app/modules/checkers/api/material.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/policy_compilation.py" + "target": "backend/app/modules/checkers/api/materialization.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/post_submit.py" + "target": "backend/app/modules/checkers/api/output_custody.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/post_submit_catalogue.py" + "target": "backend/app/modules/checkers/api/policy_custody.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/pre_submit.py" + "target": "backend/app/modules/checkers/api/pre_submit_material.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/api/pre_submit_catalogue.py" + "target": "backend/app/modules/checkers/api/selected_post_submit.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/catalogue.py" + "target": "backend/app/modules/checkers/api/selected_pre_submit.py" }, { "group": "artifacts", @@ -946,16 +966,12 @@ }, { "group": "artifacts", - "target": "backend/app/modules/checkers/compiler.py" + "target": "backend/app/modules/checkers/default_checker.py" }, { "group": "artifacts", "target": "backend/app/modules/checkers/delivery_authority.py" }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/effective_plan.py" - }, { "group": "artifacts", "target": "backend/app/modules/checkers/execution.py" @@ -986,31 +1002,23 @@ }, { "group": "artifacts", - "target": "backend/app/modules/checkers/post_submit_catalogue.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/post_submit_contracts.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/post_submit_implementations.py" + "target": "backend/app/modules/checkers/pre_submit_defaults.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/pre_submit_defaults.py" + "target": "backend/app/modules/checkers/router.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/pre_submit_execution.py" + "target": "backend/app/modules/checkers/selected_post_submit_execution.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/router.py" + "target": "backend/app/modules/checkers/selected_pre_submit_coordination.py" }, { "group": "artifacts", - "target": "backend/app/modules/checkers/runner.py" + "target": "backend/app/modules/checkers/selected_pre_submit_execution.py" }, { "group": "lifecycle", @@ -1472,10 +1480,6 @@ "group": "lifecycle", "target": "backend/app/modules/projects/post_policy/service.py" }, - { - "group": "lifecycle", - "target": "backend/app/modules/projects/post_submit_policy.py" - }, { "group": "lifecycle", "target": "backend/app/modules/projects/repository.py" @@ -1909,7 +1913,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "da53a65579c9b8d2230bd946687d90ad7d14b962de67a9cc61eab81b7793953f", + "authority_digest": "7282f1d9d49950a7252f895b27cb91a00008da1ce3c33be7e763f399366fbcdb", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/module-boundaries/private-edge-debt.v1.json b/.ci/module-boundaries/private-edge-debt.v1.json index 9a47e2df4..b91f8e28b 100644 --- a/.ci/module-boundaries/private-edge-debt.v1.json +++ b/.ci/module-boundaries/private-edge-debt.v1.json @@ -163,12 +163,6 @@ "imported_private_path": "app.modules.artifacts.service", "repair_owner": "WS-ARCH-001-04" }, - { - "source_file": "backend/app/main.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.catalogue", - "repair_owner": "WS-ARCH-001-04" - }, { "source_file": "backend/app/modules/actors/service.py", "target_module": "audit", @@ -241,24 +235,6 @@ "imported_private_path": "app.modules.tasks.models", "repair_owner": "WS-ARCH-001-03" }, - { - "source_file": "backend/app/modules/checkers/pre_submit_execution.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.sources", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/checkers/pre_submit_execution.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_archive", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/checkers/pre_submit_execution.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_manifest", - "repair_owner": "WS-ARCH-001-04" - }, { "source_file": "backend/app/modules/projects/create_router.py", "target_module": "actors", diff --git a/.commitrail/changes/pilot04-workstream-default-checker.md b/.commitrail/changes/pilot04-workstream-default-checker.md new file mode 100644 index 000000000..b4f1a1936 --- /dev/null +++ b/.commitrail/changes/pilot04-workstream-default-checker.md @@ -0,0 +1,1688 @@ +# [PILOT-04] Deliver The External Checker Flow End To End + +- Initiative: None +- Durable disposition: Planned +- Intended merge outcome: Replace both built-in checker catalogues with one + versioned Workstream default checker and immutable digest-pinned project + images, then prove ZIP-to-prechecks-to-Submission-to-post-checks without a + parallel runtime. + +## Intent + +Required activation feedback repair permits PROJECTS +`api/guide_proposals.py`, `guide_activation/service.py`, the shared +`api/deps/guide_proposal_http.py`, canonical error primitives in +`core/api_controls.py` and `main.py`, current activation contract/PostgreSQL +tests, `tests/test_api_controls.py`, the checker architecture/roadmap and this +record. Expose only bounded requirement ID, stage and title from the exact +approved compilation when missing capabilities block activation. Populate the +existing canonical `error.details`, after current PM authority and custody +checks; preserve concealed failures and refusal before activation writes. +No new route, approval/activation behavior, legacy error decoder, provider +coordinates, rationale/evidence exposure, DDL, skips or cap changes. Risk L1: +pure envelope/bounds/concealment regressions and the real PostgreSQL +gap-to-corrected-binding journey, including refusal rollback, precede full CI +and independent authority review. Human review focuses on exact approved +generation and absence of cross-project disclosure. + +Final predecessor cleanup permits `backend/app/modules/artifacts/submission_admission.py`, +`backend/tests/artifacts/test_selected_predecessor_rules.py`, its explicit module +registration in `backend/scripts/test_lane_catalogue.py`, and this record. +Require a consumed predecessor's actual selected PRE lineage and rules commitment; +reject missing selected custody before preparing bytes. Never reinterpret its +retained policy-context hash as a checker-rules hash. Preserve all stored rows, +current unchanged-work rejection and genuine rules-change acceptance. Prohibited: +schema/data changes, legacy resubmission adapters, invented hash sentinels, +skip/cap changes or broader admission rewrites. Risk is L1; focused current and +missing/contradictory-lineage regressions, existing native predecessor controls, +the final real-image drill and full CI verify it. Independent QA reviews custody +and rejection ordering; human review focuses on fail-closed retained inputs. + +Issue #491 owns the complete checker cutover. A contributor ZIP must first pass +the one Workstream default checker and every required project pre-submit image. +Only then may the existing caller-owned transaction create an immutable +Submission, move the task to `evaluation_pending`, and retain its durable +initial post-submit dispatch. Post-submit images produce evidence only. + +## Current behavior + +This branch contains the normalized external contract, immutable registry, +long-lived Rust service and SDK, typed Unix-socket client, callback-scoped ART +grant, and the exact four-behavior Workstream default checker. ART verifies the +received ZIP and canonical manifest before the default and every locked PRE +image run over one genuine prepared-material capability. The selected durable +owner takes TASK custody before CHECKERS state, commits its generation lease +before execution, runs without a database transaction, and finalizes either the +exact result or one recoverable infrastructure outcome in a fresh short root. +Only a complete pass mints the one-use admission capability consumed by TASK's +existing atomic Submission, `evaluation_pending`, evaluation reservation and +outbox operation. Work findings retain bounded evidence without a Submission. +Its closed AUTH contract now assigns those two phases to the dedicated selected +PRE service principal and binds preparation, scope, the durable request and the +exact execute/finalize fact digest without granting direct service access. +The selected POST processor consumes only actual Submission and ART material +through the phase-typed callback grant, runs every locked POST image in order, +and returns canonical evidence and observed isolation without any acceptance +or routing authority. The existing CHECKERS scheduler now recognizes that +sole external envelope, derives its lease and +execution deadlines from the exact locked images, recomputes the ART-owned +manifest before execution, and retains the complete selected result for +restart/replay. The existing production outbox registry now owns the evaluation +request handler alongside assignment invalidation, leases worker-owned ART +material only during execution, and creates the configured Rust adapter only +for a selected request. New TASK submission composition now requires selected +POST custody and refuses an unresolved legacy admission instead of executing +it; no old request/result runtime or decoder remains. Admission, +routing completion and remediation fixtures now exercise the selected PRE/POST +owners. Both obsolete executable catalogues and their isolated callers and +tests are removed together with obsolete contracts; stored immutable evidence +remains intact without an old runtime reader. F-020 recovery now proves that registration alone cannot +activate the retained warning-bearing generation and that only an exact +registered binding in a corrected, linked approval can activate. The combined +native journey now exercises real PostgreSQL 16, MinIO, the Rust service and the +digest-pinned image across pass, work-failure, infrastructure/restart/fresh +verification and POST evidence paths with database, object-store and workspace +cleanup. + +## Bounded change + +### Allowed + +- `backend/tests/test_submission_predecessor_custody.py` may replace the + unavailable-provider test double with the actual async-iterable `open` port. + Preserve the infrastructure-failure result and denial before replacement + byte access; no production provider behavior or outcome change. + +- Current selected-custody fixture closure also permits + `backend/tests/checkers/external/test_selected_pre_submit_coordination.py`, + `backend/tests/test_post_submit_selection.py`, + `backend/tests/test_post_submit_materialization.py`, + `backend/tests/projects/policy_read_faults.py` and + `backend/tests/tasks/test_payment_policy_migration.py`. + Remove obsolete call signatures/evidence-set joins and use the current + selected manifest, grant location and full context hash. Replace the old + catalogue-rule corruption with a hash-consistent invalid default identity. + Migration assertions must preserve every original value while explicitly + checking the exact additive current columns; no wildcard tolerance or row + rewrite. Keep all authority, immutable evidence and recovery assertions. + +- Hosted ART-binding fixture reconciliation permits + `backend/tests/test_artifact_bindings.py`, + `backend/tests/test_artifact_bindings_db.py`, + `backend/tests/test_submission_composition.py` and + `backend/tests/test_submission_preparation_adapter.py`, plus this record. + Replace superseded evidence-set fixtures with complete current selected-run + admission facts and exact authority inputs. Retain denial, immutable packet, + stale lineage, concurrent single consumption, rollback and fresh replay + proofs. Diagnose any database lock failure before changing cleanup; never + hide it with retry, disabling guards or adding a second admission path. + No production/schema changes are authorized by this fixture repair. Focused + pure tests and real PostgreSQL controls precede independent review/full CI. + +- The hosted transport-budget test repair permits + `backend/tests/tasks/evaluation_delivery/test_completion.py` and this record. + Configure an explicit test-owned in-memory broker before importing the actual + Celery task and clear settings custody after the check. Preserve every timeout, + lease and actual task hard-limit assertion; no production setting, worker + topology, CI cap or execution behavior changes. Acceptance is the isolated + no-broker test and existing worker topology controls, followed by full CI. + +- The bounded hosted shared-fixture repair permits only + `backend/tests/checker_output_admission_helpers.py`, + `backend/tests/test_artifact_admission_digest.py` and this record. Reconstruct + the current selected POST request through the CHECKERS repository's strict + stored-request owner, including its separately retained digest, and include + the selected PRE run's explicit null slot in the guide admission digest + expectation. Preserve real stored CHECKERS rows, ART authority, storage, + recovery, replay and immutable lineage assertions. No production, schema, + migration, compatibility decoder, old checker contract, test selection, skip + or cap change is allowed. Acceptance is focused collection, Ruff, the affected + real PostgreSQL fixture consumers and the unchanged guide replay proof. Risk + is L0 because the repair only aligns shared test inputs with current owners. + +- Final dead-contract removal permits the obsolete CHECKERS modules + `api/pre_submit.py`, `api/post_submit.py`, `api/historical_post_submit.py`, + `api/policy_compilation.py` and `post_submit_contracts.py`, their unused exports + in `api/__init__.py`, and this record. Production consumers now use the sole + external contracts and canonical packet/value primitives. Remove these + unreferenced catalogue/planner/DTO implementations rather than aliases or + replacement decoders. Preserve current public ports, schema identifiers, + stored records, migrations, AUTH and immutable evidence. Acceptance is no + production reference to the removed modules, current app/contract imports, + Ruff, full collection, focused current-owner controls and the final native + journey. Architecture reviewers inspect the combined removal; risk is L1. + +- Project POST-policy custody accepts only the locked external-image policy. + This repair permits `backend/app/modules/projects/post_policy/custody.py` + and this record: remove the obsolete catalogue union, read fallback and + activation fallback while retaining exact operation receipts, sidecar hashes, + authority, lineage and caller-owned transactions. No stored row deletion, + migration, alternate decoder, deployment or merge is authorized by this + scope. Native current-policy public reads/approval/replay/revocation and + activation controls, Ruff and relevant owner checks verify the removal. + The lead independently inspects the final diff; affected architecture and + authority reviewers replay the combined candidate. Risk is L1 because + policy custody remains fail-closed and governs activation. + +- Administrative permission-read fixture reconciliation permits only + `backend/tests/authorization/admin_access/test_admin_reads_postgresql.py` + and this record. Replace the obsolete exact permission total 78 with 80, + reflecting the selected PRE execute/finalize permissions. Preserve role, + response, authority and observation assertions. No product, migration, + workflow, test selection or implementation change is permitted. Acceptance + is both existing public administrative reads on real PostgreSQL, plus Ruff + and whitespace checks. The low-risk review checks the exact diff and native + evidence. This fixture correction has no additional roadmap impact. + +- The bounded permission-fixture/PostgreSQL CI provider repair permits only + `mcp_server/tests/test_access_reads.py`'s representative permission response + total (78 to the current exact 80), four exact PostgreSQL image source + prefixes in `.github/workflows/backend.yml` (two), + `.github/workflows/cli.yml` (one) and `.github/workflows/mcp.yml` (one), + and this record. Preserve every assertion, negative/schema constant, SDK + lock, fixture identity, test selector, service count, PostgreSQL 16 version, + OCI digest, health check, resource bound, job dependency and required check. + No backend product, migration or CLI product/test source may change. The + previous invalid-total negative remains invalid; no dynamic total fallback + or schema broadening is allowed. Independently fetched Docker Hub OCI index + bytes must match pinned `33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20`. + Acceptance is the complete existing MCP package selection, retaining and + identifying its two baseline environment-gated skips, including declarative + schema/wire and authority-revocation cases; existing lightweight workflow + controls and exact provider-only structural comparison must pass. Local + MCP 2.2 evidence is separate from hosted SDK/wire proof. + +- The bounded CI collection/selected-contract repair permits only additive + package markers `backend/tests/authorization/pre_submit/__init__.py` and + `backend/tests/authorization/post_submit_routing/__init__.py`, plus the exact + affected `mcp_server/contracts/{authorization_context_get,permissions_list, + admin_roles_list,actor_get}.json` snapshots and + this record. No production, CLI, migration, workflow, pytest import mode, + collection selection, canonical runner or test assertion changes are allowed. + Both AUTH directories sit under the existing packaged `tests.authorization` + owner and have colliding flat `test_contracts` names; package-qualified imports + must retain every original node. Reuse the selected-fragment canonical capture + functions, change only the demonstrated selected PRE ActionId/PermissionId additions, + ServiceIdentity addition, dependent permission count, hashes and source + provenance. Preserve all nine operations and the other five snapshot files + byte-for-byte, including their captured source and hash. No MCP tool is added. + Acceptance is fresh complete canonical backend collection with every node + assigned once across all ten declared lanes, plus real backend OpenAPI drift + checks and a deliberately wrong-schema negative that still fails. This repair + makes no database/runtime or hosted green claim. + +- Bounded current-document reconciliation covers `docs/roadmap_status.md`, + `docs/architecture_checker_framework.md` and + `docs/decision_0014_external_service_adapter_convention.md`. Describe selected + hidden PRE/POST and production request/completion composition, distinguish + retained predecessor contracts from removed executable catalogues, preserve + the four default blockers and warning-only summary/attestation, and retain + PILOT-08 false-guide activation and PILOT-12 public intake as remaining work. + Generated output-file authority is still deny-only; normalized JSON result + custody does not prove ART file outputs. Local runc/docker-dev proof is not + deployed or gVisor proof. Worker transport/budget code and + `docs/operations_authorization_service.md` remain lead-owned. + +- Delegated TEST-only reconciliation is limited to + `tests/projects/post_policy/test_guards.py`, `test_postgresql.py`, + `test_public_api.py`, `tests/authorization/post_policy/pg_support.py`, + `tests/projects/test_locked_policy_custody.py` and the single + `activation_case` registry-spec passthrough in + `tests/projects/guide_activation/pg_support.py` (all under `backend/`). + Use the current two-argument service, saved approved external policy and + real Operator-authorized immutable registration. Preserve authority, + transaction, custody, replay, mutation and revocation assertions. Shared + `tests/checkers/post_submit/support.py` and all production files stay untouched. + Remove only mutable catalogue injection, whose owner no longer exists; + replace its purpose with exact approved-binding version/digest substitution + refusal and unchanged locked-context reads without live registry lookup. + + Purpose mapping: the obsolete `catalogue` parametrization of + `test_exact_source_and_authority_substitutions_reject_without_writes` is + removed because a mutable catalogue is no longer a service input. Its remaining + upstream/project/link/policy-hash substitutions retain their zero-write checks. + `test_post_policy_operation_preserves_finalization_without_provider_or_evaluator_calls` + now binds one genuinely registered POST entry to the approved snapshot and + refuses a substituted capability version. The old catalogue-rollout context + case is replaced by + `test_registry_substitution_blocks_activation_but_frozen_context_never_re_resolves`: + a substituted registry digest blocks activation with unchanged state; restored + identity activates; frozen and active contexts remain identical with live + registry reads forbidden. Public response comparison uses the actual saved + approved external policy. All SQL immutability, deferred receipt closure, + replay, commit rollback, revocation and HTTP admission tests remain selected. + +- This record and affected current PILOT-04 architecture, roadmap and owner docs. +- The existing local Rust-runtime protocol fixture and selected end-to-end + probe, to distinguish actual default rejection from actual project-image + rejection and verify both leave the task in progress without a Submission. +- The existing OUTBOX prefork topology and its delivery task hard bound, with + topology and real process-containment regressions, to honor the selected + request budget without changing event-specific owner deadlines or selectors. +- The backend CI Redis service source and its exact structural regression, + preserving the official image digest, service count, ports and health checks + while repairing a reproduced registry quota failure before test checkout. +- CHECKERS policy contracts, registry reads, default/external execution, + durable attempt/result custody, post routing and composition roots. +- ART preparation, verified-material callback, evidence, admission and + post-submit materialization owners needed by the one selected runtime. +- PROJECT approval persistence and activation readiness for exact registry, + requirement, stage, configuration and resource-limit binding. +- TASK's existing submission composition and CHECKERS dispatch owners only to + compose the already-owned atomic Submission/`evaluation_pending`/outbox + operation after prechecks pass and to route post-submit evidence. +- The minimum existing hidden preparation-to-Submission composition needed to + prove PILOT-04. PILOT-12 retains assignment-bound upload intent, contributor + REST/CLI intake and requirements-read surfaces. The existing hidden command + stops rejecting absent summary/attestation so the default checker can retain + warning-only observations; this does not publish the PILOT-12 surface. +- One successor migration plus model, Alembic graph, fingerprint, reset + inventory and direct-SQL guards if existing evidence tables cannot retain the + exact external execution and latency facts. Its ordinal follows the actual + single linear predecessor at publication. +- Removal of both obsolete catalogue implementations, callers and obsolete + tests after replacement proof. +- Focused contract, AUTH, custody, replay, concurrency, failure, service, + public API, CLI, PostgreSQL and MinIO tests and their exact CI ownership. +- The CHECKERS composition adapter and existing selected PRE/POST composition + tests may defer the fixed Unix provider until a locked external entry is + actually invoked. A default-only PRE policy and an empty POST policy require + no Rust-service configuration; a nonempty policy with absent configuration + must become the existing recoverable `implementation_unavailable` outcome + after durable reservation. Partial configuration remains invalid. No second + provider, fallback executor, alternate result or lifecycle path is allowed. +- The existing CLI post-policy read DTO and its HTTP/public-socket contract + tests. Replace the removed catalogue-policy decoder with the one closed + `external_post_submit_policy.v1` response; preserve selectors, fresh read + authority, raw output, denial handling and transport failure semantics. No + legacy-response fallback or command-surface change is allowed. +- Bounded hosted-suite reconciliation may update + `backend/tests/retained_material_fixtures.py`, + `backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py`, + `backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py`, + `backend/tests/projects/guide_activation/test_admission.py`, and the existing + Retained-material callers + must use the current typed TASK submission-context port; worker tests must + await the current async composition helper; activation admission tests use + the current constructor. Historical migration fixtures may preserve raw rows + and predecessor shapes, but cannot reconstruct an executable catalogue DTO + or feed retained rows into the current scheduler. +- The selected-only contract cleanup may update CHECKERS public API value types, + execution, result classification, repository, coordination and retry owners, + plus `backend/app/modules/tasks/post_submit_routing/evaluation_guard.py` and + focused selected-flow tests. The current boundary accepts only the selected + external request/result schemas. Stored historical rows remain immutable and + readable as raw history, but cannot acquire a lease, execute, retry, finalize + or publish a completion. Selected replay compares the same exact TASK owner + projection and full locked selected-policy body as initial reservation. + TASK, assignment and Submission lock order, terminal eligibility, denial + semantics and persisted historical facts remain unchanged. + +### Not allowed + +- No second scheduler, store, submission lifecycle, acceptance operation, + policy representation, compatibility alias or parallel execution. +- No checker-owned acceptance, Review fabrication, ContributionPolicy change, + payment work, provider-path authority, host socket exposure, or external + execution inside a database transaction. +- No optional summary/attestation blocker. Their absence remains a warning. +- No generic Workstream implementation of project rules; every project + pre/post check is an approved digest-pinned external image. +- No PILOT-05, PILOT-06 or PILOT-09 work, deployment, merge or issue closure. + +## Design and decisions + +Approval resolves every project binding to an immutable phase-specific registry +row and persists its row ID, entry digest, platform image manifest digest, +schemas, configuration and resource ceilings under the locked policy hash. +Runtime never selects a latest version or trusts a proposal string as authority. + +ART computes and verifies received bytes and the canonical file manifest before +external execution. Its callback grant maps one sealed read-only workspace into +the Rust service and expires on completion, timeout, disconnect or cancellation. +The Workstream checker implements exactly archive safety, server-owned receipt +and manifest identity, high-confidence secret detection and task-rule-aware +unchanged-resubmission refusal. Missing summary/attestation yields warnings. +Every required project pre image then runs in locked order. Work failures retain +bounded findings, create no Submission or dispatch, consume no contributor +attempt and leave the task `in_progress`. Infrastructure outcomes are +recoverable and are not contributor-failure facts. + +External work remains outside database transactions. After every required +precheck passes, the caller opens one root and invokes the existing TASK +submission composition operation, which consumes the exact admission while +atomically creating Submission, advancing to `evaluation_pending`, reserving +the initial evaluation and appending the dispatch event. Post workers resolve +only locked entries, read immutable Submission material through ART and persist +normalized evidence; they cannot accept work. + +Activation refuses any required capability suggestion without an exact +requirement/stage binding to a registered digest-pinned image in the same +approved generation. Registration alone, wrong project/stage/requirement, or a +stale approval does not clear F-020. Existing false-policy and warning- +acknowledgment guards remain. + +The old pre/post catalogues are removed in the same final change after every +production caller and required proof uses the selected runtime. +Retained approvals, locked guides, evidence and completed runs are never +rewritten or deleted. No historical catalogue decoder or executable compatibility +path remains. Unsupported queue envelopes and locked policy bodies fail closed; +they cannot select a checker implementation. Existing nullable storage columns +preserve original facts without adding an old writer or reader. Native checks +require the complete guide, policy, +packet, manifest and selected-run custody whenever selected lineage is present, +so migration does not synthesize or rewrite historical facts. + +Pre execution uses a durable lease/generation fence. One root reserves the +exact prepared-generation, policy, registry and request identities, commits, +then the ART callback and external service execute without a database +transaction. A fresh root locks TASK currentness before CHECKERS attempt state, +verifies lease/generation and material/result/isolation identities, and appends +the immutable outcome or marks a recoverable infrastructure result. Expired +leases are finalized as infrastructure failures under the same locks. Because +pre-Submission prepared bytes and the grant are deliberately process-local, a +restart never reconstructs them from a DTO or path: retry requires a fresh +upload, a fresh server-computed byte/manifest identity and a new attempt +generation while retaining the failed attempt. A grant is never serialized or +resumed. Queue/read-current/reserve, completion, replay and acknowledgment all +acquire TASK custody before CHECKERS state, including the retained +intermediate-wait regression. + +## Acceptance criteria + +- [x] Approval binds the default behavior digest and every project binding to + an exact immutable registry row, phase, image, schemas, configuration and + limits; substitutions and stale approvals fail closed. +- [x] Activation enforces exact F-020 requirement/stage/generation coverage and + retains false-policy and warning-acknowledgment guards. +- [x] F-020 recovery is proven by register -> recompile -> approve -> activate + in one current generation; registration alone and wrong generation, stage, + requirement, registry digest or stale approval remain blocked. +- [x] ART verifies server receipt identity and a bounded safe manifest before + any project image reads material. +- [x] The default checker runs exactly four blockers; absent summary and + attestation remain warning-only. +- [x] Every required pre image passes before admission consumption. Work + findings create no Submission/dispatch and keep the task `in_progress`; + infrastructure failures remain recoverable and consume no attempt. +- [x] Passing preparation invokes the existing caller-owned atomic operation + and commits Submission, `evaluation_pending` and initial dispatch together. +- [x] Required post images consume immutable Submission custody, persist + normalized evidence and never decide acceptance. +- [x] Timeout, cancellation, crash/restart, changed policy/material, + cross-project access and duplicate delivery cannot create partial effects; + latency and queue/execution lineage are retained, and restart requires fresh + server-verified uploaded material rather than a reconstructed scratch handle. +- [x] Retained old evidence remains readable without rewriting hashes; unresolved + old execution cannot cross into the new runtime and requires authorized policy + transition. +- [x] Both obsolete catalogues, implementations, callers and obsolete tests are + removed without a fallback or parallel runtime. +- [x] A real PostgreSQL 16, MinIO and Rust-service journey proves pass, work + failure, infrastructure/retry and post-dispatch paths with cleanup. + +## Risk and review routing + +The migration-only archive scope includes +`backend/tests/fixtures/pilot04_predecessor_rows.json.gz`, +`backend/tests/historical_submission_fixtures.py`, and the existing retry and +replacement migration tests. Replace executable pinned-source writers and the +raw DTO facade with checksum-bound stored rows and explicit snapshot selection. +Fixture restoration is restricted to an owned isolated test database. It must +restore trigger/FK settings, validate foreign keys and named material custody, +and compare every restored row before any migration assertion receives data. +Restoring terminal archive facts is setup evidence, not proof of a current or +predecessor writer. The archive records capture/schema provenance; no product +runtime, deployment, retained-row deletion or permissive current guard is added. +Wrong checksums, unowned targets, changed schema/rows and unsupported fixture +variants must refuse. Independent CI/QA review and the preserved real PostgreSQL +migration invariants verify this fixture replacement. + +Current history and queue fixtures also replace built-in failure-name switches +with normalized registered-image verdicts. Allowed producer paths are +`backend/tests/checkers/execution/storage_fixture.py`, +`backend/tests/submission_fixtures.py`, `backend/tests/retained_material_fixtures.py`, +the project bootstrap helpers in `backend/tests/test_tasks.py`, +`backend/tests/projects/{policy_bundle_fixtures.py,unified_policy_fixtures.py}`, +and `backend/tests/committed_guide_fixtures.py`. Register and bind the exact POST +image before guide approval and TASK release. Preserve admitted immutable bytes +in the existing scripted provider for the test session factory, and traverse +current ART reads, grants, CHECKERS phase AUTH and finalization for both verdicts. +Do not retrofit locked policies, synthesize failed members, translate old names, +or add compatibility arguments. Consumer assertions remain independently owned. +Required real PostgreSQL controls cover failed/passing generations, currentness, +bounded history and review non-admissibility. Scripted provider evidence does +not claim MinIO or real container execution. Independent root review covers this +test-fixture author repair; production, DDL and public route changes are prohibited. + +The final caller trace includes `backend/app/modules/checkers/{history.py, +execution_results.py,api/history.py}`, the existing history privacy expectation, +and `backend/tests/checkers/execution/test_storage.py`. Remove retired catalogue +message lookup and severity-policy classification from history reads. Render +bounded messages from normalized status, admit the persisted selected `error` +severity, and mark required failed image members as blocking. Keep concealed +internal findings, exact parent selectors, immutable rows and read authority. +A real failed registered-image result must be readable by both contributor and +management projections without raw image messages; the regression must fail on +the prior reader. Independent authority/architecture review checks the caller +repair. Risk remains L1; no public route or stored-data rewrite is added. + +At clean `c4d590c7b706bacf926c324f587dffdaef0f4924`, the native SQL identity +substitution controls, empty-policy production request/completion acceptance, +and failed selected-result history regression passed (4 cases, 106.13 seconds). +These tests use controlled normalized image results with real PostgreSQL, +ART material and AUTH custody; they do not replace the real Rust image journey. +Restoring only the previous history reader and message table in a temporary +checkout makes the exact history regression fail with `KeyError` (42.98 seconds). +Both runs confirmed database, role and MinIO cleanup. Raw evidence is +`/tmp/pilot04-root-current-history-identity21.{log,json}` and +`/tmp/pilot04-root-history-prior-reader-mutant21.{log,json}`; the temporary +counterfactual checkout is removed. Public history schema controls passed two +cases separately. Later changes require affected evidence reconciliation. + +Final cleanup also permits `backend/scripts/behavior_ownership.py`, its existing +PILOT-04 partition transition test and `.ci/behavior-ownership/partition.v1.json`. +Retire exactly the five removed checker contracts and two removed ART PRE +writers; admit only the canonical value and PRE packet owners. Regenerate the +current partition with its existing protected base. Preserve all other target +assignments and refusal of unrelated additions/removals. The focused transition +test and architecture/ownership checks verify this metadata reconciliation; +it changes no runtime behavior or test selection. + +- Risk class: L1 +- Required reviewers: architecture, security, authorization, product_ops, + reuse_dedup, qa, test_delta, documentation, ci_integrity +- Human review focus: Locked policy/registry custody, TASK-before-CHECKERS + ordering for read-current/reserve/enqueue/complete/replay/acknowledge, + all-pre-before-Submission, transaction boundaries, durable pre lease and + process-local grant lifetime, work-versus-infrastructure outcomes, retained + history, F-020, post non-acceptance and complete catalogue removal. + +## Evidence + +| Claim | Command or proof | Result | Remaining uncertainty | +|---|---|---|---| +| Existing component custody | Prior PR538 default, Rust service/SDK, ART callback and contract reviews | Passed at recorded component heads | Final selected flow/current-main integration pending | +| PRE transaction boundary | `pytest -q` for invalid claim controls and `test_checker_processor_starts_only_after_authority_transaction_commits`, plus Ruff on the four affected files | 5 passed; processor construction is refused while the authority root is active and reached only after it commits | Durable lease/generation schema and selected external sequence remain pending | +| Selected PRE callback | `pytest -q backend/tests/checkers/external/test_selected_pre_submit.py`; Ruff and `git diff --check` on the affected files | 4 passed; exact default runs first, a real high-confidence secret prevents every external invocation, locked external pass/fail is retained, and the genuine request-bound ART grant completes cleanup with one bounded owned mount | Durable persistence, admission composition, crash recovery and native service journey remain pending | +| Selected admission binding | `pytest -q backend/tests/test_submission_change_gate.py backend/tests/checkers/external/test_selected_pre_submit.py`; Ruff and `git diff --check` on the affected files | 11 passed; existing callers retain their exact archive/manifest rejection while the selected binder carries unchanged work plus exact predecessor/current rules into the default checker | The binder is not yet selected by the durable admission caller | +| Selected durable result contract | `pytest -q backend/tests/checkers/external/test_selected_pre_submit.py`; Ruff and `git diff --check` on the affected files | 5 passed; canonical result/terminal digests bind the default, ordered external results, isolation, generation lease, outcome and measured queue/execution latency | PostgreSQL reservation, lease and finalize storage remain pending | +| Selected durable request contract | The same five selected-PRE tests plus focused Ruff and `git diff --check` | Passed; the reservation digest covers contributor/link, TASK, assignment, ART attempt/generation, policy/default, exact default input and archive/manifest custody, and the terminal includes that request digest | PostgreSQL ownership and SQL custody remain pending | +| Selected idempotent replay contract | `pytest -q backend/tests/checkers/external/test_selected_pre_submit.py backend/tests/checkers/external/test_selected_pre_submit_coordination.py backend/tests/checkers/external/test_selected_pre_submit_service.py backend/tests/checkers/external/test_selected_preparation_command.py backend/tests/authorization/pre_submit/test_contracts.py backend/tests/test_submission_bundle_admission.py`; focused Ruff and `git diff --check` | 70 passed; one actor/key namespace permits a fresh prepared generation only when every stable byte, packet, policy, TASK and assignment fact matches, validates stored terminal timing/results, replays bounded work findings or an existing durable upload, and rejects changed stable facts | Native uniqueness/immutability and concurrent PostgreSQL replay proof remain pending | +| Selected POST policy handoff | Focused selected PRE/admission/composition tests plus Ruff and `git diff --check` | 76 database-free tests passed; the complete approved POST image policy and digest now travel inside the selected PRE request, verified admission and ART consumption result, and malformed or substituted retained policy fails closed | Submission request selection and durable external POST result storage remain pending; seven separately selected routing tests required a PostgreSQL URL and were not claimed | +| Selected durable owner source | `pytest -q` over selected PRE, policy, coordination, PROJECT activation/proposal contracts and architecture module boundaries; focused Ruff | 51 focused/architecture tests passed; the selected run is its own durable attempt identity, TASK locks precede CHECKERS state, one-use admission facts are substitution-resistant, ordered deadlines fit ART custody, and four private module edges were removed through public ports/composition | Native migration, exact AUTH SQL closure, crash reclaim and admission composition remain pending | +| Selected lease execution boundary | `pytest -q backend/tests/checkers/external/test_selected_pre_submit_coordination.py backend/tests/checkers/external/test_selected_pre_submit_service.py backend/tests/checkers/external/test_selected_pre_submit.py backend/tests/architecture/test_module_boundaries.py`; focused Ruff | 49 passed; fixed-service preparation precedes owner locks, the lease commits before material execution, execution occurs with no root open, and exact lease/result digests are consumed in a separate finalization root | Native SQL custody and live admission selection remain pending | +| Selected PRE authority contract | `pytest -q backend/tests/authorization/pre_submit/test_contracts.py backend/tests/authorization/test_catalogue.py backend/tests/checkers/external/test_selected_pre_submit_service.py backend/tests/architecture/test_module_boundaries.py`; focused Ruff and `git diff --check` | 50 passed; one dedicated service identity owns only execute/finalize, prepared selectors bind project/run/request, exact phase facts bind the decision and audit digest, direct service calls remain unavailable, and composition uses the public authority port | Native actor/action/evidence constraints and live workflow injection remain pending | +| Selected POST callback | `pytest -q backend/tests/checkers/external/test_selected_post_submit.py`; Ruff and `git diff --check` on the affected files | 3 passed; actual Submission/ART identity reaches every locked POST image through its short-lived grant, work pass/fail remains evidence only, and infrastructure failure stays recoverable | Durable POST reservation/finalization, worker restart and routing composition remain pending | +| Selected durable POST contract | `pytest -q backend/tests/checkers/external/test_selected_post_submit.py backend/tests/test_submission_bundle_admission.py`; Ruff and `git diff --check` on the affected files | 56 passed; the request digest binds the exact approved selected policy, separately named locked TASK context and native Submission/ART identity, while the result digest binds request/generation, ordered execution evidence or a closed recoverable infrastructure category without acceptance authority; a legacy catalogue hash cannot stand in for the selected image-policy digest | The existing scheduler/materializer still needs to select this contract and persist it through restart/replay | +| Selected POST scheduler compatibility | `pytest -q backend/tests/checkers/external/test_selected_post_submit.py backend/tests/checkers/execution/test_results.py backend/tests/checkers/execution/test_completion_contract.py backend/tests/checkers/post_submit/test_request.py backend/tests/test_submission_bundle_admission.py`; focused Ruff | 84 passed; one discriminated owner accepts retained legacy and selected request/result envelopes, selected leases cover the ordered image budget and finalization margin, ART recomputes the selected manifest, and focused scheduler controls prove exact completed/infrastructure envelopes and generic replay parsing | Native selected columns/constraints and live Rust worker proof remain pending | +| Selected POST native custody | `python3 /tmp/p04_selected_runner.py /tmp/p04-selected-post-result-replay 420 tests/checkers/external/test_selected_post_submit.py::test_selected_post_materializes_actual_submission_and_replays`; focused selected-POST pure tests and Ruff | PostgreSQL 16 PASS 1 in 36.10s with database/role cleanup complete, plus 7 pure tests; actual immutable Submission and verified ART lineage materialize through the selected policy, finalize selected result/member custody and replay the stored strict envelope | The approved policy is empty in this discriminator; a locked nonempty image, live Rust service and worker restart remain pending | +| Nonempty selected POST policy and result | `python3 /tmp/p04_selected_runner.py /tmp/p04-selected-post-nonempty5 600 tests/checkers/external/test_selected_post_submit.py::test_selected_post_persists_locked_external_member`; `pytest -q backend/tests/checkers/external/test_selected_post_submit.py -m 'not postgres_schema_contract'`; focused Ruff and `git diff --check` | PostgreSQL 16 PASS 1 with database/role cleanup complete, plus 7 pure tests; an actual immutable registry entry is registered through Operator authority, compiled into one supported POST requirement, approved, activated, materialized from the real Submission/ART lineage and persisted as the exact selected member. Persisted UUID-bearing policy JSON and SQL requirement custody are decoded and validated without relaxing strict in-memory contracts. | The adapter in this discriminator is bounded and deterministic; a live Rust service, restart/reclaim and external work/infra outcomes remain pending | +| Actual selected POST Rust service | `run_selected_post_probe.py` through the isolated PostgreSQL runner at `/tmp/p04-selected-post-probe-final2`, using local registry platform manifest `sha256:c664d8d85d6dfcded31710223c8d91441548e5445f0aa9b59d246fc464a129ef`; focused Rust POST-grant round-trip test, Ruff and `git diff --check` | PASS 1 in 45.06s on PostgreSQL 16/schema 0037 with database/role and callback workspace cleanup complete. The real Rust service validates the full POST grant, launches the digest-pinned container in explicit local `runc`/`docker-dev` mode, reads every verified manifest file, returns an exact isolation receipt, and the normal owner persists its selected member. | This local proof does not claim hosted `runsc`; restart/reclaim, live PRE orchestration and final end-to-end MinIO evidence remain pending | +| Actual selected PRE Rust service | `PYTHONPATH=.:backend /tmp/workstream-pilot-review-locked-env/bin/python experiments/pilot04_external_checker_runtime/run_selected_pre_probe.py --image-reference localhost:35104/ws-pilot04-checker@sha256:c664d8d85d6dfcded31710223c8d91441548e5445f0aa9b59d246fc464a129ef --service-binary external_checkers/target/debug/workstream-checker-service`; Ruff and `py_compile` | PASS. Genuine prepared ART bytes and the server-computed manifest enter the selected default-plus-project-image processor before any Submission exists. The callback-scoped grant launches the exact digest-pinned image through the Rust service in explicit local `runc`/`docker-dev` mode, returns matching result/isolation custody, and confirms the workspace is empty after the callback. | This is the actual selected PRE execution boundary, not the final PostgreSQL/MinIO admission-to-dispatch journey or a hosted `runsc` claim. | +| Production delivery registration and owner boundaries | `pytest -q backend/tests/architecture/test_module_boundaries.py backend/tests/test_artifact_architecture.py backend/tests/outbox/test_contracts.py`; focused Ruff and a direct registry construction probe | 116 passed; the existing production outbox registry exposes assignment invalidation and evaluation request handlers, selected Rust configuration is lazy, and PRE coordination stays behind CHECKERS' public adapter root | Database-backed legacy/selected delivery and worker restart proofs remain pending | +| New Submission selected-only boundary | `pytest -q backend/tests/test_submission_bundle_admission.py backend/tests/checkers/external/test_selected_post_submit.py backend/tests/test_artifact_architecture.py::test_checker_custody_ports_have_exact_owner_and_art_consumers`; focused Ruff | 59 passed; retained legacy and selected admission projections are mutually exclusive, and the TASK writer accepts only selected POST custody for a new immutable Submission | Native rollback proof for a retained unresolved legacy admission remains pending | +| Obsolete catalogue removal | Focused lane/architecture/ownership suite; recursive collection of every modified surviving test module; Ruff, module-boundary inventory and stale-import scan | 84 focused tests passed and 94 surviving tests collected. Ten obsolete executable catalogue/runner targets, their private-edge debt and tests are absent; exact ownership metadata admits only the selected owners and those retirements. | The stacked branch still has separately tracked structural-debt growth from earlier P02/P10/P04 source, so the repository-wide structural-debt gate is not claimed by this deletion checkpoint. | +| F-020 recovery | `python3 /tmp/p04_selected_runner.py /tmp/pilot04-f020-native6 600 tests/projects/guide_activation/test_postgresql.py::test_checker_registration_requires_corrected_approval_before_activation`; focused external-policy tests and Ruff | PostgreSQL 16 PASS 1 with schema 0037 and database/role/MinIO cleanup complete, plus 6 pure compiler tests. A real Operator registration does not clear the existing warning generation; correction observes the current registry projection, a linked successor approval binds the exact row/digest, the successor activates, and the old generation remains refused. Wrong stage, requirement, identity and registry digest substitutions fail closed. | This proves the activation recovery gate, not the combined intake-to-POST journey. | +| Retained admission migration shape | `pytest -q backend/tests/test_submission_bundle_admission.py backend/tests/checkers/external/test_selected_pre_submit_coordination.py backend/tests/checkers/external/test_selected_preparation_command.py backend/tests/test_artifact_architecture.py::test_checker_materialization_contract_has_one_canonical_consumer_owner`; focused Ruff | 62 passed; selected rows require complete new custody while every new column stays null on retained legacy rows, avoiding fabricated backfill or historical hash rewrites | Successor SQL and direct retained/selected row controls remain pending | +| Owner/caller trace | PROJECT approval/activation, ART preparation/admission, TASK composition and CHECKERS dispatch | Selected PRE uses durable reserve/lease/finalize roots around the genuine ART callback; a pass is consumed only by TASK's existing atomic Submission/evaluation reservation/outbox composition, while POST uses immutable Submission/ART custody | Independent final integration review remains | +| Default behavior and warnings | `pytest -q backend/tests/checkers/default/test_contract.py backend/tests/checkers/default/test_execution.py backend/tests/checkers/external/test_selected_pre_submit.py -m 'not postgres_schema_contract'` | 30 passed. The manifest and executable behavior retain exactly four ordered blockers; empty summary and attestation produce warnings without changing eligibility. | Full required checks remain on the final reconciled head. | +| End-to-end replacement | `run_selected_end_to_end_probe.py` through the isolated runner, `/tmp/pilot04-selected-e2e10.log` and `.json`, using platform manifest `sha256:c664d8d85d6dfcded31710223c8d91441548e5445f0aa9b59d246fc464a129ef` | PASS on PostgreSQL 16/schema 0037 with database/role/MinIO/workspace cleanup complete. Real MinIO PRE and the Rust/runc image commit one Submission, `evaluation_pending` and dispatch, then actual Submission/ART POST evidence. A secret finding leaves a separate Task `in_progress` with no Submission. Service loss retains `implementation_unavailable`; restart uses a fresh upload and independent ART verification, records a completed successor generation and commits exactly one later Submission/dispatch. | This local proof uses explicit `runc`/`docker-dev`; hosted CI must exercise its configured runtime and the final reconciled head. | + + +| Delegated current post-policy callers | Exact `c1195f8b2d55bc02976efe41d6052bca8b975a66`: six focused PostgreSQL cases in `/tmp/pilot04-post-policy-c119-native-callers.log` and producer metadata `.json`; scoped Ruff and `git diff --check` | 6 passed in 104.91s, schema `0036_selected_checker_flow`, database/role cleanup complete. The registered digest substitution refuses activation with unchanged activation facts; restored registration activates, and active/frozen reads never consult registry. Public approval/replay/revocation and commit rollback, plus canonical AUTH derive/read/approve evidence pass. | Local PostgreSQL 16.15 uses an owned 512 MiB tmpfs and 256 MiB memory bound with fsync/synchronous_commit/full_page_writes enabled. This test-only proof uses local ART and scripted setup, not MinIO or runtime evaluation; final combined source and hosted CI remain separately verified. | + +The registered-one-entry projection and exact approved-binding version rejection +also passed at `397e8812c`, with that test/source blob unchanged at `c1195f8b2`. +The companion registry-refusal test initially expected `proposal_stale`; its +actual canonical readiness outcome is `approval_blocked`, corrected and verified +in the six-case proof above. Two broader selections reached their unchanged +240-second child budget (physical-disk reset IO, then a selection too large for +that focused budget); both cleaned their databases/roles and are not reported as +complete module passes. All surviving tests remain in their canonical CI lanes. +The delegated reconciliation changes no capability or public exposure, so it has +no independent roadmap change beyond the parent cutover's documented scope. + +## Reconciliation + +- Current-source reconciliation: implementation starts at PR538 head + `7453a0d8c5664cfd486872a381899f3755393004`; final publication will reconcile + actual merged `main` and intervening owners. +- Next usable boundary: independent full-scope review and final required CI; + no component-only merge. +- Remaining risk: reconcile the final stacked migration and owner metadata with + actual `main`, then validate the complete required test union on that tree. + +## Production completion repair + +Independent review found that the production outbox registry selected evaluation +requests but omitted the existing completion handler. The earlier real Rust/MinIO +probe called the executor directly and ended at `evaluation_pending`; it did not +prove worker completion routing. The registry now composes the canonical +completion handler. The new native production-registry test exercises request +delivery, emitted completion delivery, acknowledgment and duplicate refusal for +both human review and shared automatic acceptance with complete awards. Its +runtime result remains pending until the isolated PostgreSQL command completes. +Publication must remove the accidentally stacked PILOT-02/PILOT-10 source and +reconcile the selected-flow successor with the actual merged migration chain. + +## External PRE configuration repair + +PRE bindings previously forced configuration to an empty object, so an otherwise +registered PRE image with required configuration could never be approved. PRE now +uses the existing bounded flat CapabilityParameter contract, enforces unique +parameter names and validates the exact registered schema in both compilation +and approval. Configuration arrays are normalized to JSON arrays for both +phases. Empty PRE parameters are excluded during serialization, preserving +retained empty-binding hashes. Focused compiler regressions cover configured +PRE success, required/type rejection, duplicate names and retained serialization. + +### Selected replacement cause repair + +After reconciling merged replacement intake and the operator-retry branch, source +review found selected PRE lineage omitted the remediation manifest. The repair +binds the exact cause through canonical reservation JSON, TASK revalidation, +ART final authority and ready-admission facts. Initial attempts require no cause; +replacement attempts require a UUID cause alongside the predecessor. Focused +proof covers cause round trips, publication and substitution denial. The first +contract run passed 120 tests; the expanded run passed 121 with one stale AUTH +fixture missing required packet/manifest facts. That fixture is being corrected +without changing authority guards. PostgreSQL currentness and full Rust/MinIO +proof remain pending on the final reconciled migration. + +The reconciled AUTH composition exceeded its existing 1,200-line boundary. Move +its exact artifact-binding matcher to the existing materialization binding owner, +with explicit context/digest inputs and unchanged predicates; do not alter the +structural debt ledger or its limit. + +The first reconciled Rust probe exposed a branch-reconstruction error: TASK's +repository reverted the merged `select_submission_context` operation while its +service still called it. Restore the merged repository operation, retaining +selected-policy decoding, and make selected PRE locking use the same canonical +TASK context service as all intake callers. This also retains CHECKERS currentness +validation for replacement attempts. The failed probe's owned database was +cleaned; rerun the real service proof after the repaired candidate is frozen. + +Configuration repair: PRE and POST machine configuration accepts bounded +canonical relative paths/patterns through the existing CHECKERS syntax validator, +while retaining command, credential and control-character guards. Actual model +prose fields remain unchanged. The affected 127 contract/policy tests pass; an +independent 18-control replay confirms the former valid-path rejection is fixed. + +The first reconciled native owner run failed four cases at deferred Submission +dispatch custody, before completion/replacement assertions. The new selected +admission has no historical evidence row, but merged replacement dispatch SQL +still used an inner join to that row. Preserve the replacement cause predicates +and adapt that join for the explicit selected lineage. All owned database, role +and MinIO resources were cleaned. No passing full-flow claim is made for that run. + +Full-cutover cleanup after the user's clarification: remove the unused production +historical catalogue factory and historical Submission-content adapter. Move +only the frozen metadata needed to seed actual predecessor schemas into +`tests/historical_checker_metadata.py` and the migration-only Submission fixture. +No product adapter constructs the removed catalogue. New Operator retries only +construct selected external requests; immutable receipt replay is preserved. + +Real local selected Rust/MinIO run at fbb397a passed default/PRE/Submission/POST +and production completion to review_pending, default-secret rejection without a +Submission, and unavailable-PRE recovery through fresh verified bytes. All owned +DB/role/MinIO/scratch cleanup passed. The runtime was runc/docker-dev, not gVisor. +The same-head native run passed the real production automatic acceptance case +(with exact contribution and both awards); replacement failed at strict UUID +lineage construction. Normalize the native PostgreSQL remediation UUID to the +TASK public port's Python UUID representation rather than weakening validation. +Replacement proof and runtime-budget alignment remain pending. + +Runtime budget repair aligns selected POST work, ART custody and the production +request transport; only request events receive extended delivery bounds. Preserve +unknown-event refusal before invocation and authorized immutable finalization +replay by resolving bounds independently of current handler registration; the +existing claim/invocation guards still require a registered handler. Focused +unknown-registration and selected production delivery regressions are required. + +Canonical multi-project fixtures reuse only an exact already-registered immutable +checker entry via the public registry reader, asserting the full spec digest; +otherwise they publish through real Operator authority. They do not duplicate +registrations or bypass registry custody. Selected replacement test substitutions +normalize a native cause UUID at the typed fixture boundary. + +Static selected policy budgets are validated before compilation and approval. +POST rejects both a single excessive image and an excessive aggregate; PRE +rejects aggregate work beyond the platform custody ceiling. Lower deployment +limits still produce a recoverable infrastructure refusal at runtime. Focused +policy, outbox and transport verification passed 56 tests in the local locked +environment; this is not final-head full-suite CI evidence. Native 434b9e0d3 +passed replacement replay, unknown registration and automatic acceptance; its +two cause guards stopped at a native UUID fixture boundary. Normalize that +fixture identifier only, preserving the strict production registry reader. +Database, role and MinIO cleanup completed; cause-guard rerun remains pending. + +The selected cause-guard rerun reached the intended SQL immutability denial +(SQLSTATE 55000) but exposed two superseded test assumptions: successor requests +were rebuilt with the historical DTO, and mutation refusal was caught as an +integrity violation instead of DBAPIError. Use the exact selected constructor +for selected fixtures and retain the exact 55000 assertion. No production guard +or database constraint changes are part of this test repair. Reconcile roadmap +claims to selected hidden delivery, keeping public intake and hosted isolation +proof explicitly outside the delivered surface. Final guard rerun is required. + +Real Celery transport must cover selected POST work, not just the asynchronous +owner timeout. Its fixed prefork hard bound is now the maximum lease plus a +60-second shutdown margin (3660 seconds), with exact task/message validation +to reject shorter or longer overrides. Per-event bounds remain selected request +3520/3600 and ordinary events 240/300. Preserve the existing scaled real-prefork +kill/reap/UNKNOWN regression while scaling both the installed test bound and its +entry guard; no production duration is shortened for verification. + +Current-document reconciliation distinguishes selected hidden production +composition from public intake and false-guide activation. Immutable predecessor +contracts are retained provenance, not executable catalogue fallback. Source +inspection confirms output-file storage/binding is deny-only while selected +images produce closed JSON results. This editorial repair adds no runtime +proof; the lead owns final-head native cause guards, worker timing and hosted CI. + +Final repaired native evidence: cause-substitution and intermediate TASK-lock +wait controls passed 3 tests in 186.12s at 11bd7bda0. Real prefork kill/reap, +UNKNOWN persistence and independent hard-limit checks passed 2 tests in 25.81s +at 2d111d8c7; topology and transport controls passed 24 tests in 3.65s. Owned +database, role and MinIO cleanup completed for both native runs. The selected +Rust/MinIO probe at 89df6a34a passed actual default and external PRE rejection +without a new Submission, passing PRE-to-Submission/dispatch-to-POST/completion +to review_pending, fresh completion restart/replay, and unavailable-service +recovery through fresh verified bytes. Image platform manifest is +sha256:c2d957341116c2324299461598330bf93937786c30658a4b71ef9392e9b78278. +Its runtime is local runc/docker-dev, not hosted gVisor. Database, role, object +store and scratch cleanup passed. Later documentation commits change no product +code from that tested head. Full final-head hosted CI remains required. + +Fresh hosted CI at c0ffb0f47 found one current-roadmap vocabulary violation +(corrected to Celery composition) and two lanes failed before checkout on an +anonymous public-ECR Redis Data limit exceeded response. Use the identical +official Redis OCI index from Docker Hub instead: independently fetched bytes +hash to the same pinned 520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7. +Only the two registry prefixes and exact structural expectation change; all +services, digest, health checks, test selection and caps remain unchanged. +New-source CI verification is required; those failed lanes ran no tests. + +Fresh CLI public-contract CI then reached the current backend successfully but +rejected its selected external POST policy locally as `invalid_api_response`. +The CLI still decoded the removed catalogue schema. The bounded caller repair +uses a strict closed external-policy/registry DTO, rejects the historical active +catalogue shape and retains the existing public authority, replay and transport +controls. Focused Go packages and five HTTP contract tests pass. The strengthened +real PostgreSQL public-socket node publishes a genuine Operator-authorized POST +registry entry, derives one nonempty locked binding and passes in 96.64 seconds +at code/test head `84087f49245482ecf775a6d68dc83a849f9ebe0a`, schema head +`0036_selected_checker_flow`, with complete isolated database/role cleanup. It +uses the actual current backend JSON rather than a copied nested fixture. This +record-only descendant changes no tested CLI or test bytes. The prior hosted +143-pass/one-fail run is retained as the failing-before discriminator. + +At clean `a9547a49b42c246d355b818e63c68ed85fa6270a`, the unchanged canonical +`run_test_lanes.py --collect-only` collected 8,887 unique nodes in 228.06 seconds, +with zero deselections and complete, nonoverlapping assignment to all ten lanes. +Both namespaced AUTH families are retained: three selected PRE contracts and +96 post-submit-routing nodes. Producer manifest and per-lane inventories are in +`/tmp/pilot04-a954-collection-canonical/`; raw collection output is +`/tmp/pilot04-a954-collection-canonical.log`, with generated metadata in +`/tmp/pilot04-a954-collection-metadata.json`. This is collection proof, not +backend test execution or lane-runtime evidence. + +All eleven MCP selected-fragment tests passed in 37.93 seconds against a fresh +backend OpenAPI command. A deliberately added counterfeit ActionId produced the +intended one drift-check failure with ten other checks passing in 1.24 seconds. +Logs are `/tmp/pilot04-a954-mcp-current-canonical.log` and +`/tmp/pilot04-a954-mcp-wrong-enum.log`. Canonical capture is from backend source +`3fde44797607d4c187dc62f93876e6ae70d5c555`; this repair leaves that backend code +unchanged. The local contract tests reuse the existing MCP 2.2.0 environment, +with the backend command in the locked backend environment; hosted SDK/wire and +full required CI proof remain separate. Five unaffected snapshot files retain +exact bytes, source and hashes; all nine operations are unchanged. Initial +harness setup failures (invalid output-parent layout and an incompatible backend +MCP SDK for test conftest) are retained in raw logs and are not test results. + +Hosted e7 CLI checks found one formatting-only failure in the public policy +regression. Apply the repository formatter; Python AST equality before/after +confirms no test behavior change, preserving the native 8408 producer proof. + +The fixed Unix checker provider is now resolved only when a locked external +entry executes. Nineteen focused runtime and selected-PRE tests pass: an empty +PRE policy runs the actual verified-archive default with no grant or provider +construction, while a nonempty policy with absent settings opens its ART grant +and returns the existing recoverable `implementation_unavailable` category. +The production empty-POST request/completion route also passes against real +PostgreSQL with absent service settings, zero persisted external members and +complete database/role cleanup (`/tmp/pilot04-optional-runtime-post.log` and +`.json`). Strict partial-setting validation and the sole fixed provider remain +unchanged. Full final-head hosted CI remains required. + +At clean `769d117a7230af053ac7ff63c52f6b35b674c21b`, the entire existing MCP +package selection passed 367 tests in 47.56 seconds using the existing local +MCP 2.2.0 environment. Its two baseline environment-gated skips remain exactly +the fresh backend OpenAPI comparison and clean-wheel independence proof; each +has a separate required hosted invocation. Declarative schemas, exact GET wire, +projection, invalid response and post-revocation controls execute in this suite. +The old invalid-total negative is unchanged; the representative positive fixture +alone now matches the backend's exact 80. Raw output is +`/tmp/pilot04-permission-pg-mcp-package.log`. This is not hosted SDK 2.3 wire or +container/build proof. + +Existing lightweight workflow controls passed all 20 tests in 2.309 seconds +(`/tmp/pilot04-permission-pg-lightweight.log`). Independently fetched official +Docker Hub PostgreSQL 16 OCI index bytes are 10,237 bytes and hash exactly to +`33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20` +(`/tmp/pilot04-postgres-33f923-dockerhub-manifest.json`). Reversing only the four +provider-prefix replacements yields byte-identical original workflow text: +backend two services, CLI one and MCP one. All version/digest, health, PostgreSQL +configuration, resource bounds, job dependencies and required checks are retained. +No container or image layers were provisioned for this source repair. Actual +new-source hosted service startup, SDK/wire, build and full CI remain required. + +Refreshed frozen-head native evidence at 953e9f9ab452f982160ff12d1d8ca8794e4564bf: +production empty POST with absent external settings passed 1 test in 49.19s. +This supersedes the earlier optional-runtime sidecar whose recorded HEAD was +e7 while the author tested uncommitted changes; do not attribute that earlier +run to the later frozen commit. The configured Rust/MinIO flow also passed at +953 with the same c2d957 platform image: default and external PRE rejection, +passing atomic Submission/dispatch through POST and completion to review_pending, +restart/replay and unavailable-service recovery via fresh verified bytes. Both +isolated runners record schema0036 and completed DB/role/MinIO cleanup; runtime +remains local runc/docker-dev. Later README/roadmap reconciliation is prose-only. + +Correction dispatch loads the immutable checker projection in its own explicit +root after capturing AUTH context and ending identity reads. Its product request +still starts in a fresh transaction and rechecks current manager authority before +committing or publishing. The former dependency assumed identity resolution had +left a root open: a valid detached identity and fresh request session raised +`ExternalCheckerRegistryUnavailable` before reaching either admission or denial. +The existing public manager-handoff regression reproduces that failure at +`2f1f46930` in 23.18 seconds with isolated schema `0036` and cleanup complete; +the targeted trace locates the missing registry-read transaction. Final proof +uses the normal public owner, with diagnostic exception capture removed. + + +### TASK/ART single execution flow cleanup + +The current TASK and ART consumers now accept only the closed external POST policy +and selected PRE run custody. TASK admission/replay no longer decodes catalogue +checker content, and ART material selection requires selected PRE admission and the +exact submitted external policy. ART removed its obsolete PRE evidence writer, +invocation store, phase wrapper, old capability issuance, and durable publication +and replay branches. Immutable historical models and columns remain retained; +these are not executable checker contracts. + +The selected PRE materializer still requires the committed lease, callback-scoped +genuine ART grant, exact generation/manifest/bytes and default input digest before +execution. The mounted failed-PRE projection accepts only selected execution facts +and retains the existing sanitized default and external findings. The existing +AUTH protocol fields remain stable, but runtime submission admission rejects an +old evidence-set lineage. No migration or catalogue fallback is added. + +Validation at this source checkpoint: TASK/ART Ruff and Python compilation pass; +current owner/DTO imports pass. Native and broader current-flow checks remain +required after the CHECKERS composition and selected fixture repairs are combined. +This checkpoint does not claim hosted suite readiness. + + +### Current ART projection and runtime proof reconciliation + +ART input selection now uses the single POST context identity and requires its +hash to equal the retained submitted external policy digest. Binding authority +projects the sole selected PRE run and a constant null historical evidence key, +matching the unchanged authorization protocol. The private durable put result and +publish call expose one selected run only. PRE execution input requires the lease +attempt, request digest and genuine ART grant port, with no optional old plan. + +The selected PRE unit arrangement now uses canonical reservation and committed +lease facts instead of the retired plan/invocation claim. It retains real scratch +inspection and the callback grant and proves that substituting the default packet +fails before byte access or external invocation. The actual Rust end-to-end probe +constructs its registry specs from the pure external contract helper; it no longer +imports the obsolete PRE arrangement merely for a spec. The open PRE schema, +digest pinning and resource limits remain the same. + +Focused selected PRE processor, current ART factory/admission publication and +short-root execution service checks pass (11 cases). Ruff and diff whitespace +checks pass. This is author verification; independent review and the final clean +combined PostgreSQL/MinIO/real-image journey remain required, as does full hosted +CI. No migration or provider proof is claimed by this checkpoint. + +### Hosted selected-test reconciliation + +The hosted cutover artifacts expose obsolete catalogue test callers alongside +real owner defects. This test-only repair replaces live fixture references to +removed structural request/consumer/result contracts with selected external +contracts and deletes cases whose sole purpose is built-in catalogue execution. +Immutable retained data remains; no historical runtime/DTO path is restored. Authority, full lineage, lease/lock, cleanup, rollback, exact replay and +review-source proof must remain. No aliases, fallback providers, skips, selector, +timeout, migration or production edits are allowed. + +Allowed files: this record; backend/tests/checkers/execution/{support.py, +test_concurrency.py,test_coordination.py,test_execution.py}; +backend/tests/checkers/post_submit/{test_requirement_dispositions.py, +test_public_boundary.py}; backend/tests/authorization/post_submit/{test_live_authority.py, +test_timeout.py}; backend/tests/test_post_submit_materialization.py; +backend/tests/test_post_submit_selection.py; backend/tests/test_agent_runtime.py; +backend/tests/tasks/post_submit_routing/{support.py,test_evaluation_guard.py, +test_evaluation_currentness.py}; backend/tests/reviews/decision/support.py. +Parent/peer owns admin permission counts, TASK port fixture wiring, current +owner corrections, compilation coordinator awaits and migration baselines. + +Acceptance: updated live tests target the sole selected execution contract; +retained required checks keep their discriminating negatives; deleted obsolete +semantics have an explicit retained-proof mapping. Focused pure/current-contract +checks and native authority/material/locking cases are required, followed by the +lead's complete canonical suite. Risk is test-integrity: independent test-delta +review checks each deletion and preserves authority/evidence behavior. + +Additional causal test scope (peer confirmed disjoint): +backend/tests/checkers/execution/{test_material_lineage.py,test_storage.py, +material_storage_helpers.py,storage_fixture.py}; +backend/tests/authorization/post_submit/test_receipt_custody.py; +backend/tests/authorization/task_locked_context/test_visibility.py. These repair +selected result/member SQL fixture shapes and retain current closed-policy +visibility, immutable lineage and action receipt controls. The unsupported raw +envelope guard is retained at the current owner entry. Retained raw row migration +custody remains peer-owned proof. + +Explicit user cleanup supersedes prior retained-runtime/decoder test preservation. +Allowed additional files: backend/tests/checkers/post_submit/support.py and +removal of test_configuration.py, test_input_bounds.py, test_request.py and +test_result_contract.py in that directory. These obsolete tests enforce removed +historical catalogue/rule request contracts, not current selected execution. +Current selected PRE/POST/default tests retain exact closed input/result bounds, +phase/id/digest/stage binding, infrastructure/work separation and no acceptance. +Stored immutable data remains; no active historical DTO/factory/terminal executor +path is recreated. The unsupported raw envelope negative retains the no alternate +execution boundary. Exact module inventory deletion is coordinated with the lead +for the final canonical exhaustive suite; no test selection/cap is weakened. + +Deleted semantic families: built-in catalogue configuration/selection, old +structural request DTO field/aggregate bounds, nine-member generic result shapes, +and disabled mandatory catalogue readiness. Required current proof remains in +checkers/external/{test_contracts.py,test_policy.py,test_selected_pre_submit.py, +test_selected_post_submit.py}, checkers/default/{test_contract.py,test_execution.py} and unified +project guide compilation contract tests (unknown/stale/stage/typed config). +Authority, callback lifetime, post-I/O revocation, cleanup failure, crossed native +lineage/receipts, exact replay, complete selected member closure and review-source +mutation tests are updated rather than removed. + +First focused pure scope passed 5 tests in 21.53s before the backend selected-only +API checkpoint; raw /tmp/pilot04-selected-test-pure-current.log. The wider caller +collection failure from module-level old output fixture imports is retained at +/tmp/pilot04-selected-test-pure-first.log. Full composed collection/native proof +remains required after peer source/shared fixture cleanup; this is not green CI. + +The TASK-to-ART selected-source query now requires the exact immutable +selected_post_submit_policy_body. Its privacy test asserts that field is present, +while exact raw guide/review/revision policy bodies, package URI, summary and +attestation remain absent. The previous blanket policy_body substring assertion +rejected the required selected policy projection; exact project selectors and +no FOR UPDATE read contract remain unchanged. + +Further current test-consumer scope, explicitly handed off by the peer: backend/ +tests/checker_output_admission_helpers.py, tests/test_tasks.py, +tests/test_checker_output_custody.py, tests/test_submission_bundle_preparation_recovery.py, +tests/checkers/execution/test_completion_contract.py, removal of +tests/checkers/test_phase_service.py. The obsolete phase adapter is removed; +selected PRE ownership and selected POST consumer/current completion tests retain +phase isolation, canonical result binding and no unchecked callback shortcut. +Output reservation fixtures derive exact stored current request facts, not a +constructed unrelated catalogue request. Unsupported output access remains denied. + +Further explicit lead scope: backend/tests/test_pre_submit_evidence_relock.py and +backend/tests/test_submission_bundle_admission.py; peer handoff also permits +backend/tests/checkers/external/test_selected_preparation_command.py. Remove only +old evidence writer/pass-capability publication phases. Retain current adapter, +preflight-denial-before-bytes, TASK/actor/PROJECT/grant order, root-only authority, +header/media, status projection and cleanup controls. Post-byte drift moves to +the actual selected preparation command and proves no coordinator execution or +durable intent on failed relock. Exact selected admission capability single-use, +construction/substitution and locked lineage remain in +test_selected_pre_submit_coordination.py; selected command/service and actual +root Rust/MinIO failure/replay evidence cover no admission on work/infrastructure +failure. No old writer or live historical factory is reconstructed. + +The zero-member/locked-one-image direct SQL negative now binds fresh final AUTH +to the same counterfeit facts and bypasses request validation only for that +exact counterfeit, simulating a faulty application. This prevents a mismatched +valid AUTH digest from masking the locked-policy completeness guard. The normal +request validator remains active for every other result; the final positive +control uses the original complete result. No product checks are weakened. + +### Selected-only migration-test caller reconciliation + +Explicit lead/peer ownership permits test-only edits to +`backend/tests/checkers/execution/test_material_migration.py`, +`backend/tests/checkers/execution/test_checker_retry_migration.py`, +`backend/tests/tasks/post_submit_routing/test_requests.py` and removal of +`backend/tests/historical_checker_metadata.py` after its only caller is converted +by the raw predecessor-fixture owner. Preserve retained-row equality, failed +preflight rollback, observed migration/writer exclusion and the Operator-receipt +supersession guard. Archived JSON is updated and hashed as raw facts; current +product decoders never accept it. The live completion-denial fixture uses the +sole selected current-reference API. No production, migration, fingerprint, +workflow, cap or skip changes are allowed. Acceptance requires fresh full +collection after the peer raw producer checkpoint, focused current contracts, +and isolated native migration/SQL controls where the claim depends on Postgres. +This is author test reconciliation, requiring lead/QA review in the complete +candidate; it does not establish hosted readiness. + +### Deleted-test inventory reconciliation + +Lead authorizes `backend/scripts/test_lane_catalogue.py` and +`backend/tests/test_ci_lane_catalogue.py` solely to remove the five modules +deleted above: the old phase-service test and built-in POST configuration, input, +request and result tests. Remove the matching four POST exact-set entries. All +remaining modules, exhaustive discovery, ten lanes, partition algorithm, markers, +execution caps and required CI controls remain unchanged. Canonical collection +and exact inventory tests must pass; CI/test-delta reviewers inspect the deleted +module basis in the complete candidate. No current selected/default or custody +test is omitted. + +The exact composed source `e8b5ae1d6f31c414f962e5bff1646788eed536fc` passed +the native locked-one-image/self-consistent-zero-member SQL control in 22.93 +seconds on installed `0036`. Fresh AUTH binds the same counterfeit, SQL refuses +completed custody, all run/member/completion writes roll back, and the restored +complete result finalizes. DB, role and MinIO cleanup all passed. This closes the +focused policy-completeness proof; broader native/full hosted checks remain +required. Raw evidence: `/tmp/pilot04-selected-e8b-zero-native.log` and `.json`. +The earlier installed-function syntax failure remains a failed prerequisite +attempt, not a counterfeit-result proof. + +Fresh full discovery on the raw-producer composition collected 8,731 tests with +one import error: the current default-checker test still imported its packet +from the deleted PRE DTO module. The current-consumer test scope therefore +includes `backend/tests/checkers/default/test_execution.py` for the exact neutral +`api.pre_submit_material.SubmissionPacketView` import correction only. All four +default checks and their controls remain required; no deleted DTO is restored. + +### Same-count selected-result SQL identity controls + +Lead authorizes two additional native controls in the already-owned +`backend/tests/checkers/execution/test_storage.py`: a self-consistent foreign +registry UUID (outer and normalized member agree) and a substituted observed +platform image digest under the unchanged locked one-image policy. Each must +first fail the real current request validator, then simulate a faulty validator +only for that exact counterfeit. Fresh final AUTH binds the same counterfeit; +forcing all deferred constraints must refuse the write, preserve run/member/ +completion/audit facts on rollback, and permit the restored valid result. These +controls test SQL custody independently of the application guard. No product or +DDL edits, mismatched earlier AUTH evidence, fixture bypass, or weakened checks +are permitted; the sole migration author owns any reproduced SQL repair. + +Current-consumer scan found a body-local import missed by collection: the +already-owned PROJECT post-policy PostgreSQL test patched the removed phase +wrapper as its no-execution sentinel. Replace only that sentinel with +`PostSubmissionExecutor.evaluate_post_submission`. Preserve actual approved +external policy, proposal finalization, provider/document denial, derive/approval +replay and unchanged finalization state. This is a required current isolation +control, not obsolete catalogue behavior; the old wrapper is not restored. + +### Plain archived migration consumers + +Lead assigns migration consumers to this lane and raw execution/base producers +to the QA/backend owners. Allowed test paths: execution/test_material_migration; +authorization/post_submit/test_migration; tasks/post_submit_routing/{test_migration, +test_outcome_migration}; reviews/{packet,decision,acceptance,lifecycle}/test_migration; +contributions/records/test_migration; the existing reviews/acceptance and +contributions/records historical_support helpers; and one migration-only +`backend/tests/migration_ancestor_rows.py` for literal ancestor queue, lease, +packet and Review rows. Keep these separate from current packet/review fixtures. +No dict-versus-current compatibility switch, old model facade, archived service +subprocess, production or migration edits are allowed. Plain request/result/ +material/lease dictionaries and direct SQL carry predecessor facts only. Preserve +retained-row equality, immutability, preflight refusal and observed writer locks; +current public/native tests own current execution and economic behavior. Risks +remain L1 fixture/custody reconciliation, requiring QA/test-delta review, fresh +collection and discriminating native predecessor checks before readiness. + +The raw migration consumers no longer call the current packet fixture's alternate +factory hook. Its now-unused `completed_source_factory` parameter is removed from +`backend/tests/reviews/packet/support.py`; the current fixture always uses the +same canonical `completed_source`. This exact helper cleanup adds no raw/current +switch, changes no current source behavior and retains all current packet tests. + +The two identity controls reproduced missing native custody at clean `154062bde`: +all deferred constraints accepted the foreign registry UUID and image digest +while the current request validator rejected them. Both failed with the intended +`DID NOT RAISE` in 62.64 seconds, with complete resource cleanup. The sole schema +author supplied the exact policy identity guard; the controls now pin its +`checker terminal selected policy differs` message and SQLSTATE `23514`, not an +unrelated integrity error. Affected native replay follows that frozen DDL; the +original failed evidence is retained. + +### Current external-policy and normalized-verdict consumers + +Lead/QA handoff permits current caller changes in +`backend/tests/projects/post_submit_fixtures.py`, actual test bodies only in +`backend/tests/test_tasks.py`, `backend/tests/authorization/task_authority/test_submission_policy.py`, +`backend/tests/tasks/test_project_display.py`, `backend/tests/tasks/test_locked_context.py`, +`backend/tests/authorization/submission_history/{test_reads,test_privacy}.py` and +`backend/tests/test_review_queue_persistence.py`. QA owns producer/helper definitions +and the registry-before-activation bootstrap. Use explicit approved image specs +for work-failing/member-visible fixtures and normalized passed/failed verdicts; +never translate old built-in names or create failed evidence under an empty policy. +Remove only obsolete platform catalogue assertions, retaining exact locked policy +identity, corrupted-read refusal, zero effects, privacy, parent isolation, stable +successor display and current generation guards. Current registered capabilities +replace old catalogue labels in summaries. No source, migration, CI, cap or skip +changes are authorized. Native verification follows combined QA producer freeze. + +### Remaining raw migration callers + +Lead authorizes the final caller paths: `backend/tests/tasks/test_payment_policy_migration.py`, +`backend/tests/tasks/test_submission_lineage.py`, `backend/tests/checkers/execution/test_migration.py` +and `backend/tests/tasks/post_submit_routing/test_migration.py`. The literal +base handoff also requires the single admission-ID dictionary access in +`backend/tests/tasks/submission_dispatch/test_migration.py`. Replace attribute +access over archived request/result facts with dictionary indexing. The packet +custody upgrade uses the literal revision-0021 Submission snapshot supplied by +the backend owner, never the current preparation callback on a predecessor +schema. Preserve every retained-row, preflight, immutability and negative +assertion; no production, producer, migration, compatibility helper, test cap or +selection changes. Collection and native predecessor proof follow the frozen +literal-base handoff. Both strengthened identity controls passed at exact +clean `e947161412b1a7dcc85ffcb0a7bac75922c4cab8` in 34.94 seconds with the exact +guard message and SQLSTATE `23514`, installed schema 0036 and complete cleanup. +Plain archived material supplies its semantic manifest digest; the payment +migration uses the canonical session engine, without reintroducing fixture DTOs +or engine facades. + +The frozen literal-base handoff removes unused `provision_checker` keywords +from the owned execution/material/AUTH migration tests. The existing +`backend/tests/contributions/records/historical_support.py` selects the explicit +paid snapshot for the actual installed predecessor revision (0015, 0018 or +0024) through the canonical session owner. Its unused award-shape and arbitrary +option parameters are removed: unsupported shapes fail at the function boundary, +not by silently dropping requested awards. Preserve the two/four exact award +assertions and complete/incomplete retained-award refusal; no runtime policy or +award synthesis, producer, schema or test-selection changes. +The same owned raw migration files remove the obsolete current-writer seeding +column/restore calls and imports. Literal snapshots must load into the exact +installed predecessor schema, without temporary current ART/remediation columns. +Every migration assertion and row/custody/lock check remains in place; the shared +helper owner removes unused definitions after its remaining consumers are traced. + +### Scratch format cutover cleanup + +User explicitly requires no backward compatibility in the selected checker flow. +Allowed files: `backend/app/modules/artifacts/preparation.py` and +`backend/tests/test_artifact_preparation.py`. Remove the superseded scratch marker +upgrade, version-2 ledger coercion, and their upgrade-only test. Retain current +marker/ledger validation, concurrent initialization, bounded scratch custody and +restart recovery. No retained business data, authorization, migration schema, +provider runtime, CI selection or resource limits change. Verify the full scratch +preparation test module and independent affected source review before readiness. + +The same cleanup authorizes deleting the now-unused `add_current_art_seed_column` +and `restore_predecessor_evidence_schema` functions from +`backend/tests/migration_fixtures.py` after the raw callers were reconciled. +A repository search found no remaining callers. The separately consumed project +guide fixture context remains unchanged; no shared live consumer is deleted. + +The first scratch-module run passed 93 current cases and exposed one remaining +obsolete version-two normalization test. Remove that upgrade-only test as part +of the same authorized cutover; rerun the complete current scratch module. + +### Retained dispatch upgrade assertion reconciliation + +The composed native batch passed three retry/replacement migration controls and +exposed the dispatch test's outdated additive-column assertion after upgrading +to current 0036. In `backend/tests/tasks/submission_dispatch/test_migration.py`, +replace the single 0034-column allowlist with the exact 0034/0036 columns on +Admissions, Submissions and CheckerRuns. Every added value must remain NULL; +all retained rows/values, counts, authority and no-dispatch assertions remain +exact. No automatic/dynamic column exclusion, backfill, schema change, relaxed +selection or skip. Rerun the failed node and independently review the delta. + +### Archived project ownership query + +The native paid-0018 discriminator at clean `70424eedb` passed archive and checker +source preparation but stopped on a fixture assumption that Submission stores a +project ID. The raw ancestor query now joins its stored TASK for that field, +matching the schema owner without a request fallback or invented column. Every +remaining Submission field used by this helper is checked against the canonical +model. All migration assertions and actual SQL guards remain unchanged; only the +same paid discriminator is rerun after this repair. + +### Archived queue and claim custody + +The subsequent paid discriminator at clean `d524a4090` reached the raw queue +fixture and correctly refused its pending-insert deferred event after an active +lease was added in the same transaction. In the same allowed raw ancestor helper, +commit the literal pending queue without a lease, then claim that stored queue in +a separate transaction by inserting its lease and updating the queue pointer. +This matches the predecessor SQL lifecycle without disabling guards or invoking +a current Review service. Audit the remaining queue/lease enums, packet identity, +review counts/digests and terminal updates against the installed predecessor +guards before rerunning only the paid discriminator; retain all assertions. + +### Retired PRE database write refusal + +The current-schema cutover now refuses every new write through the retained PRE +attempt/evidence tables while preserving their immutable rows for historical +inspection. New bundle intent and admission rows must carry selected PRE lineage; +valid archived evidence can no longer open the removed writer path. The five +legacy trigger functions are reduced to unconditional SQLSTATE 55000 retained-data +refusals, so no catalogue reconstruction or attempt-completion implementation +remains executable at the current head. An archived 0034 source regression clones +each old row shape and proves intent, admission, attempt, evidence, result and +attempt-update refusal with byte-exact retained-row equality. The installed-body +test validates every PL/pgSQL function and asserts these functions contain only +the refusal contract. A fresh PG16 install reached the canonical fingerprint +boundary after all function validators and measured +`f510310623f222209cbbee89291edb8e1582d65ddaf9d173d534dd796ab68991` in +199.51 seconds; isolated database, role and MinIO cleanup all completed. This +measurement used the uncommitted repair tree and is fingerprint evidence, not a +frozen-head positive runtime claim. Current selected PRE/Submission/POST native +flow must pass again after integration. + +### Archived terminal member envelope + +The literal terminal fixture now encodes raw archived member columns with the +required `post_submit_structural_result` protocol tag, matching the predecessor +0008 SQL reconstruction. Supplied counts, routing, severity and material remain +literal; a conflicting supplied tag refuses. The pure bare-member control passes +and fails on the prior helper at the intended envelope equality assertion. +Ruff and diff checks pass. This repairs fixture serialization only; the completed +0008 native migration discriminator remains required before claiming that path. + +### Shared authorization contract replay + +Reconcile the current exact action inventory and fixed-service binding snapshots +in `authorization/setup_finalization/test_catalogue.py` and +`authorization/test_assignment_invalidation_contract.py` with the selected PRE +actions/context. Diagnose and repair the controlled executor deadline fixture in +`authorization/post_submit/test_timeout.py` while retaining revocation, replica +drift, cancellation, source/workspace/projection cleanup and zero-terminal-effect +controls. The boundary waiter must expose an executor failure that occurs before +the consumer rather than report only its own timeout. Scope is these tests and +necessary shared test support; no production authorization, timing cap, schema or +runtime behavior is authorized to change by this fixture reconciliation. + +The diagnostic exposes `checker lease progression is invalid` before the consumer: +the old 12,345-second sentinel also extended the real selected execution lease +beyond its installed one-hour guard. Use a controlled 300-second budget and its +existing finalization margin; the event-armed real timeout and ten-second test +boundary remain unchanged. The diagnostic failed its intended native node in +113.79 seconds with database/role cleanup complete (683 producer with the waiter +change, not a frozen-head replay). Both current pure modules pass all 35 cases; +Ruff and diff checks pass. The complete deadline module must pass on the frozen +repair before closing its cancellation and cleanup proof. + +### Captured service principal prerequisites + +The static predecessor archive now carries the exact project-neutral POST service +actor `01a13047-9e70-7b29-a0c9-f30981caec3b` and active identity link +`01a13047-9e71-7f5e-88ea-ee2d1b04ac68` in the 15 applicable 0010-through-0024 +snapshots. Both rows were captured by writer `926b4ef118db3708e50d1869db5ee78a79e5b5de` +in the named `0034_checker_retry_source` snapshot; provenance therefore describes +these snapshots as composite captures rather than claiming every row came from +one fresh database. No retry run, audit, grant, receipt or project-bound row is +copied. A decoded old/new comparison proves exactly 30 supplemental rows and +identical seeds, revisions, tables and values everywhere else. Those rows remain +unchanged in the additive archive, now checksum-bound at +`e24ab79e764452f92d41bf93ecb00c6a9e08fae427145c51a3e55160108ed054`. +This supplies a retained predecessor identity prerequisite; canonical migration +and receipt proof remains required. + +### Literal routing-success predecessor fixture + +The retained 0031-to-0032 routing upgrade control must not execute the removed +checker or routing runtime against a predecessor schema. A one-time isolated +capture at source commit `3e2d59ba426a950e64b8e1632f4587c3265b0ea7` +produced one genuine human-review outcome and one genuine automatic-acceptance +outcome, restored every temporary current-only seed column, and then proved the +original receipt, context, governed outcome and acceptance predicates before +the existing upgrade passed. The committed +`0031_routing_success_true_false` snapshot contains all 579 rows across the 76 +nonempty public tables in that exact predecessor database. Its provenance names +the source revision, producer, capture shape and the two manifest IDs. + +Allowed scope is limited to the checksum-bound predecessor archive and loader, +`tests/tasks/post_submit_routing/test_remediation_migration.py`, and this record. +The migration test restores literal rows into an owned isolated 0031 database, +revalidates both complete success closures before upgrade, upgrades to 0032, +and requires byte-identical retained owners plus the exact new recommendation. +No archived writer, old request/result decoder, checker service, temporary +current column, guard bypass or compatibility API remains in the committed test. +The one-time generator passed the original migration control in 173.92 seconds +with complete database, role and object-store cleanup. The first current-loader +replay passed in 110.52 seconds with the same complete cleanup; its metadata +identifies the clean parent because the archive/test repair was not committed +yet, so this is qualified working-tree evidence. The first clean-head replay +passed in 227.07 seconds with complete cleanup, then independent review required +stronger non-vacuity assertions for the two policy branches. The final test +therefore requires exactly `[true, false]`, two requests, one Final Acceptance, +one ContributionRecord and exact automatic-manifest-to-acceptance-to-contribution +lineage. At clean `48bc8d741a0796292aaed6d19d99f1ca1f59c859`, that final +control passed in 228.89 seconds with database, role and object-store cleanup +complete. Independent review verified the exact archive provenance, both branch +counts and links, unchanged retained rows and installed 0032 validity predicate. + +### Literal second-review predecessor fixtures + +The two 0023-to-0024 ReviewPolicy controls must preserve the retained-true +refusal and valid-false upgrade without running the removed policy/checker +owners against a predecessor schema. At exact source commit +`3e2d59ba426a950e64b8e1632f4587c3265b0ea7`, the unchanged original nodes +passed together in 394.45 seconds with complete database, role and object-store +cleanup. A one-time follow-up capture then emitted separate exact 0023 databases +after the genuine true and false policy writes and after every temporary +current-only seed column was removed. Each snapshot contains 240 rows across 33 +nonempty public tables and exactly one ReviewPolicy with the named boolean. + +Allowed scope is limited to the checksum-bound predecessor archive and loader, +`tests/projects/review_policy/test_migration.py`, +`tests/projects/guide_activation/source_fixtures.py`, +`tests/migration_fixtures.py`, and this record. The committed migration tests +restore literal true/false snapshots and retain the original exact refusal, +unchanged policy/selector/hash checks, current-head upgrade and installed CHECK +constraint assertion. Current guide compilation now calls its current writer +directly. The unused predecessor-shape wrapper and its cause-column add/restore +helpers are deleted after repository search found no callers. No old policy +model construction, private mutation service, temporary column, compatibility +alias, guard bypass or archived writer remains in the committed tests. + +### Frozen cutover verification before publication + +At clean `835737909aa3844d6cda1a0605a3098ed73781c1`, the canonical isolated +PostgreSQL/MinIO runner passed three current-schema controls in 245.53 seconds: +all installed PL/pgSQL functions compile; new writes through retired PRE shapes +are refused with exact SQLSTATE and retained-row equality; and the current +selected admission/dispatch/empty-POST production completion path reaches shared +false-policy acceptance with complete contribution/award facts and exact replay. +Runner exit was zero and database, role and object-store cleanup completed. +Installed fingerprint is `f510310623f222209cbbee89291edb8e1582d65ddaf9d173d534dd796ab68991`. + +Independent SQL/TASK/ART source reviews and archive review passed their named +frozen heads. The full current scratch module passed 93 tests after both format +upgrade implementations and upgrade-only tests were removed. Ruff and all six +current documentation/process gates passed. Canonical collection at `835737909` +found 8,754 unique tests across ten lanes with no exclusions; this is collection, +not exhaustive execution. + +The repaired completed-0008 discriminator passed at clean `70424eedb` in 90.06 +seconds; its prior exact-source control failed with the intended terminal-member +error in 89.96 seconds. Paid-0018 preservation passed at clean `0749dfb10` in 96.33 +seconds, reaching the actual submitter record and exact two awards through 0019. +Both canonical runners exited zero with full database/role/object-store cleanup. +Fixture join and queue/claim transaction repairs received independent test-delta +review without changing retained-value, refusal, count or zero-effect assertions. +Prior fixture failures and bounded runner timeouts remain qualified in raw logs; +a timed-out runner is never counted as a pass even when its pytest log completed. + +Remaining raw migration controls, a fresh final image drill and full required +hosted CI remain necessary before human merge approval. The actual Rust image +proof at `6fd3fde80` uses local runc/docker-dev and does not claim hosted gVisor, +public contributor upload/CLI or an exhaustive suite. No deployment or merge. + +### Hosted checker execution fixture reconciliation + +Lead-authorized test-only scope: `backend/tests/checkers/execution/` files +`test_checker_retry.py`, `test_concurrency.py`, `test_execution.py`, +`test_storage.py`, `test_checker_retry_history_replay.py`, plus +`backend/tests/checkers/external/test_registry.py` and this existing record. +The hosted 683 logs retain the exact failed nodes. Production, migrations, +fingerprints, workflow caps, skips, inventories and shared producers are prohibited. + +The former lease constant no longer controls a selected policy lease. Arrange a +short first lease through the actual budget/margin inputs in a scoped test patch, +then wait for its persisted expiry using the real database clock. Restore normal +budgets before takeover; all native expiry, generation, AUTH and stale-finalizer +guards stay enabled. Completed retry negatives use the supported failed-image +`needs_revision` outcome instead of the removed built-in setup classification. +Unreadable bytes forbid the current selected consumer, not a retired registry. +Storage negatives require the current exact guard message and SQLSTATE 23514, +retained-row rollback and valid recovery. The retry-history journey supplies a +controlled selected executor before handler composition and requires a stored +completed successor before deriving its completion source; it retains actual ART, +AUTH, governed remediation, replacement and no-byte-reopen replay. The registry +mock implements only the current exact-row read and keeps all conflict assertions. + +Risk: bounded test fixture changes can otherwise hide a lifecycle failure; require +lead/independent test-delta review, focused pure registry controls and canonical +isolated native retry/takeover, byte-failure, storage and later-Submission replay +cases with exact heads and complete DB/role/MinIO cleanup. No current failure +boundary, assertion, cap or test selection is removed to obtain a pass. + +### Hosted PROJECT fixture reconciliation + +Allowed scope is limited to current external-policy PROJECT test fixtures and +their assertions: `tests/projects/guide_compilation/test_capability_growth.py`, +`test_capability_growth_postgresql.py`, `test_compilation_storage_limit.py`, +`test_live_worker.py`, `test_context_builder.py`, +`proposals/test_postgresql.py`, the active-guide read fixtures and activation +tests, `tests/projects/policy_read_fixtures.py`, +`tests/projects/policy_read_faults.py`, +`tests/projects/test_policy_read_composition.py`, their proposal/finalization +context helpers, and the matching assertions in `tests/test_projects.py`. The batch may +update current registered capability projections, strict worker error fakes, +current proposal fields and current compiler identifiers. It must not restore a +built-in checker catalogue, legacy decoder, execution fallback, migration or +product compatibility path. The bounded production repair may update only +`app/modules/projects/authorization_reads.py`, `service.py`, +`guide_compilation/approval_custody.py`, and `api/locked_policy.py` to require +the sole current external pre/post policy schemas and their self-excluding +digests. It removes generic canonical-hash and old-plan fallbacks without +rewriting retained rows. + +The final hosted capability-growth repair is limited to this record and +`tests/projects/guide_compilation/test_capability_growth_postgresql.py`. Its +non-oversized branch must expect the current `policy_draft_ready` handoff: one +immutable sufficiency report, one draft submission-artifact policy and the two +exact linked projection operations. It must retain both original gap requirement +IDs and stages while proving that no effective, PRE or POST checker policy exists +before project-manager approval. The oversized terminal branch, canonical byte +measurement, single runtime call, stored-result replay, audit count and +provider-construction refusal remain unchanged. No product, schema, guard, +workflow selection or timeout change is authorized by this repair. + +### Complete-source custody inventory performance + +Lead-authorized bounded test-only performance scope: this existing record and +`backend/tests/test_artifact_architecture.py`. The schema lane timed out before +finishing its exact sorted execution plan; the first remaining node was a custody +import-spelling negative. A single unchanged node passed locally in 41.82 seconds. +No per-node hosted duration was captured, so this identifies avoidable repeated +source parsing rather than proving the sole timeout cause. + +Freeze a complete actual APP Python-source import inventory once per test module, +using immutable module-name sets. The positive still validates every discovered +source path and exact custody consumer set plus composition annotations. Each of +the ten spelling negatives rebuilds the real `app/main.py` import inventory with +its injected import and replaces only that path in a copied complete inventory; +no shared AST or fixture may be mutated. Require a valid unmodified inventory +before each injected negative so an unrelated baseline error cannot satisfy it. + +Prohibited: production files, workflow/runner/schema caps, selectors, skips, +whitelists, reduced discovery, mutable cached ASTs, or synthetic inventories that +bypass actual source. Risk is lost structural enforcement. Acceptance requires +all existing complete-module tests and ten spelling negatives, same-source +before/after complete-module timings, and discriminating rejection of an actual +extra custody consumer with unchanged exact expected consumer sets. Lead and QA +review the immutable inventory and negative non-vacuity; human review focuses on +complete discovery, spelling coverage and isolation between parameter cases. + +The unchanged complete module passed all 35 cases at clean `95d410810`: +767.93 seconds within pytest and 812.541 seconds including process lifetime. +Its ten custody negatives consumed 228.93 seconds and five composition negatives +198.39 seconds under host contention; these are local measurements, not inferred +hosted timings. The same allowed module now also freezes actual complete-source +composition imports and ordered call names once. Call multiplicity stays intact, +including the three observability constructions. Each of the five existing alias +negatives validates the actual baseline then appends only its freshly parsed +injected file to an immutable inventory copy. All exact owner paths, construction +counts, spelling variants and required assertions remain. Require the same 35-case +module and discriminating composition/custody mutants before claiming this repair. + +At clean `7cd949a646b115b265a4dd723a17366c291edf5e`, the same complete +35-case module passed in 118.99 seconds within pytest and 140.073 seconds including +process lifetime. Both baseline and optimized subprocesses exited zero within the +unchanged 1200-second bound and had identical start/end heads and clean trees. +Host contention and cache conditions differed, so these are measured local runs, +not a guaranteed hosted speedup. The optimized complete-source fixture setup took +17.87 seconds for composition and 9.62 for custody; the fifteen injected negative +calls took 0.02–0.12 seconds each. No test, spelling, exact consumer path, call +multiplicity or assertion was removed. + +An out-of-tree CPU probe compiled the actual parser, validators and negative test +functions from that frozen source and read all 507 actual APP files. It confirmed +that inventory summaries contain only immutable scalar containers, both complete +baselines validate, all fifteen ordinary negatives pass, and no inventory changes +between cases. Changing custody consumer equality to keys-only and disabling the +eight composition ownership assertions caused the expected `DID NOT RAISE` failure +for every one of the ten custody and five composition negatives. This is a pure +source/mutation discriminator, separately qualified from the complete pytest run; +it makes no database, native lifecycle or exhaustive hosted-suite claim. Raw +before/after and mutant evidence remains available to the lead. Independent QA +source review covered the complete discovery and custody extraction; composition +addendum review remains necessary for the final union. Ruff and diff checks pass. + +### Protected AUTH structure shrink after reviewed fixture updates + +Lead-authorized scope: `backend/tests/test_projects.py`, +`backend/tests/authorization/setup_finalization/test_catalogue.py`, +`.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json`, and this existing record only. +At clean `94687d444`, canonical observed debt differs from the existing ledger: +the exact active-action comparison reaches 121 lines against its 120-line hard +limit, and the already oversized PROJECT test file grows from 5821 to 5826 lines. +Protected main is `51c7e2eed53b4fd45b3ae201ef3b30445c233a09`. Updating the ledger +would improperly admit new debt and growth, independently refused by the trusted +transition validator. The two obsolete PRE debt deletions already in the ledger +are legitimate and remain unchanged. + +Extract the exact expected action literals into one module-level immutable +frozenset; retain actual ACTIVE enumeration and equality in the existing test. +Confirm exact before/after member identity and rejection of missing and extra +actions. In the PROJECT fixture, strict stored-policy validation already enforces +the code-owned default behavior digest, so remove the duplicate comparison and +unused import. Remove only the two redundant None checks: retained compiled-row +attribute assertions still require the row, and equality with the strict validated +SHA string requires the stored bundle hash. Preserve compiler/version, current +policy hash, empty entries and every DB lineage assertion. The file must shrink +below its protected-main size without formatting or whitespace manipulation. + +Risk: loss of exact action/compiled-policy proof. Lead and CI/test-delta review +must verify unchanged inventory and negative discriminators plus strict default +validation reachability. Prohibited: production, policy/limits, test skips, new +debt, ledger exceptions, suppressed guards or any unrelated test/assertion edit. +Acceptance requires exact canonical observed-ledger parity, protected-base +shrink-only transition and retained new-debt/growth refusal controls. No native +lifecycle proof or full hosted-suite claim is added by this metadata repair. + +At clean `25742a230207880315db3dd66daa1ec501db267c`, eight focused pure tests +passed in 414.37 seconds (447.322 seconds including process lifetime), exit zero +and identical clean start/end heads. They exercise actual active inventory, +complete structural-ledger parity, protected-main transition/assertion maps, and +retained new/grown/unshrunk debt, stale-hash and policy-limit refusals. A separate +actual-source discriminator proves the exact 117 action literals are unchanged: +removing either PRE action or activating an extra planned action fails equality. +It also confirms only the three approved duplicate assertion nodes were removed; +all other PROJECT test-body AST is identical. The retained postconditions reject +missing compiled rows, null stored hashes and substituted default behavior even +with a recomputed valid outer policy digest. Independent QA reviewed that frozen +source and ledger with no required finding. This is pure structural/test evidence; +PROJECT native custody and the complete hosted suite remain separate obligations. + +### Exact selected PRE catalogue expectations in shared foundation tests + +Lead-authorized test-only scope: this existing record, +`backend/tests/authorization/admin_access/test_grant_reads_postgresql.py`, and +`backend/tests/authorization/catalogue_fixtures.py`. Fresh hosted shared-A evidence +at `da76eabed` fails only stale expectations for the two current selected PRE +execute/finalize permissions and active actions. The catalogue defines 80 +permissions; the system-auditor read test still expects 78. The separate explicit +audit-allowed action set omits `checker.pre_submit.execute` and +`checker.pre_submit.finalize`, although both are active and their fixed-service +expectations are already exact. + +Update only 78 to 80 in the exact public permission total/items comparison and +add those two literal action strings to the independent audit expectation. Keep +five role definitions, grant/profile/link identities, exact scope, denial and +zero-effect assertions unchanged. Preserve the actual audit-schema test body, +including permission/action mismatch, planned-action refusal, missing/unknown +action, decision evidence and digest validators. No production, authority grants, +DDL, ledger/limits, skipped tests or dynamically derived expected inventory. + +Risk: masking an authority expansion through a count-only fix. Verify the exact +two catalogue additions and unchanged fixed-service bindings, pure audit contract +and missing/extra expected-set discriminators, plus the actual PostgreSQL-backed +system-auditor permission read and write refusal through signed public callers. +Native verification is serial with the other owner and uses only isolated owned +resources. Lead CI/test-delta review and complete hosted CI remain required; no +publication, merge or full-suite claim follows from these fixture repairs. + +### Receipt-expiry fixture repair after the schema-lane cap + +Allowed scope is the current receipt-custody fixture in +`backend/tests/authorization/post_submit/test_receipt_custody.py`, the expired +execution delivery control in `backend/tests/tasks/evaluation_delivery/test_custody.py`, +the two unused constants in `backend/app/modules/checkers/execution.py`, and this record. +The six AUTH expiry controls patch an unused `LEASE_SECONDS` name while the +current owner derives its lease from the selected execution budget and finalization +margin. Hosted SQL timestamps show six real expiry waits of roughly 120 seconds +each, contributing to the unchanged 1200-second schema-lane cap. + +Use a scoped one-second initial execution budget and nine-second finalization +margin, then restore the current owner settings before takeover/finalization. +Wait for the persisted database expiry and assert database time has reached it; +retain genuine AUTH receipt substitution, exact rollback, valid recovery and +expired-delivery refusal controls. Remove the unused `LEASE_SECONDS` and +`EXECUTION_TIMEOUT_SECONDS` declarations; no executing owner reads either name. +Do not change production budgets, database facts/guards, lane placement, caps, +nodes, skips or exemptions. Required validation is Ruff/diff, exact seven-node +collection, independent source review and serialized real PostgreSQL controls. +This is an author repair; no completed native or full hosted-suite result is +claimed before those checks finish. + +The changed sources pass Ruff, Python parsing and `git diff --check`. +Focused collection retains exactly seven nodes: the five execute-receipt +substitutions, finalization's execute-chain substitution, and expired delivery +execution. Exact-word scans find no remaining references to either removed +constant. Neither changed test file nor the execution owner has a protected +structural-debt entry; the existing behavior ownership target is unchanged. +Lead source review found no issue with scoped setting restoration, persisted +ten-second duration, real database expiry or retained custody/refusal assertions. +Serialized PostgreSQL execution remains pending; this validation does not claim +the schema lane or the full hosted suite passed. + + +### Final hosted-failure repair verification + +The first full hosted run at candidate `da76eabed` (tested merge reference +`7b9a2f3a`) passed seven backend lanes. Shared A failed only the stale permission +count and missing PRE audit-action literals; project B failed only the stale +capability-growth draft expectation. Schema execution reached its unchanged +1200-second cap while making progress; six obsolete lease patches caused roughly +720 seconds of real expiry waits. These are diagnosed failures, not a full-suite +pass. The repair batch leaves every lane, node, CI limit and production execution +budget unchanged. + +At clean `7e17883e`, both capability-growth PostgreSQL cases passed in 183.83 +seconds: exact current draft output/projection/finalization links, retained +capability gaps, no effective PRE/POST policies, oversized terminal refusal and +exact replay without provider construction. At clean `9813a7b0`, twelve pure +audit contracts passed, the independent inventory discriminators rejected missing +and extra actions, and two signed public PostgreSQL read/write-denial controls +passed in 27.78 seconds. At clean `c5d28178`, all seven receipt/delivery controls +passed in 545.95 seconds within the unchanged 600-second local bound, proving +persisted ten-second initial leases, real database expiry, exact AUTH receipt +rejection, rollback, valid recovery and expired-delivery refusal. All three native +runners installed `0036_selected_checker_flow` and verified owned database/role +and MinIO namespace cleanup. The receipt fixtures use the current local ART +provider; this is not a new real-image or MinIO byte proof. + +The integrated source at clean `85b083279` is byte-identical to those reviewed +repair files. Lead and independent peer source/test-delta review passed, including +review of the reviewer-authored receipt changes. Ruff and Commitrail checks pass. +Canonical collection at that exact head contains 8,776 unique nodes in all ten +lanes, zero skips/deselections, and no exclusions; collection is not execution +evidence. Removing two unreferenced executor constants adds no runtime behavior. +The exact `da76eabed` Rust/PostgreSQL/MinIO image drill and capability-activation +proof remain separate earlier-head evidence. Fresh full hosted CI on the +published repair candidate and authenticated completed-node evidence verification +are still required before merge readiness. No deployment or merge is authorized +by these local results. + +### Exact identifier classifications after obsolete-owner removal + +Lead-authorized metadata-only scope: this existing record and +`backend/scripts/identifier_generation_classifications.json`. Published `62b661bd` +hosted schema evidence reports exactly three unclassified generation sites and +four stale classifications. The semantic-manifest UUIDv5 owner moved from deleted +`pre_submit_evidence.py` to `submission_manifest.py` without changing its digest +identity expression. The deleted `PreSubmitAttemptStore.reserve` UUIDv4 nonce has +no current generation site. Two `OutboxDelivery._finalize` UUIDv4 calls remain +AUTH request/correlation transport tokens; reviewed expiry checks changed the +exact enclosing AsyncWith fingerprints. + +Remove only those four stale keys and register the three observed exact keys. +Keep the manifest's `uuid5:call:content_identity` and both outbox calls' +`uuid4:call:transport_token` meanings. Verify the manifest remains a canonical +content identity supplied to current selected ART admission, while the outbox +values are passed only as request/correlation IDs to the current authority port. +Do not add a category, generic owner wildcard, suppressed generation site, +scanner/policy change, production/test/DDL edit, skip or weakened UUID assertion. + +Risk: incorrectly classifying a row surrogate as a token or hiding future code +movement. Acceptance requires all 24 unchanged identifier-inventory pure tests, +zero current unresolved/string-UUID references, exact missing-current and +stale-key mutation refusals, and unchanged unrelated classification entries. +Lead CI/test-delta review checks exact owner/key/category fidelity. No native +runtime or full hosted-suite claim is made by this metadata reconciliation. + + +At clean `33c47d683`, all 24 unchanged identifier-inventory tests passed in +34.05 seconds (52.789 seconds including process lifetime), exit zero and identical +clean start/end heads. The independent actual-scanner discriminator copied both +affected owner files byte-for-byte: original metadata reproduced exactly three +unclassified and four stale sites; corrected metadata had no errors; deleting +each of the three current keys or restoring each of the four stale keys produced +its intended error. Those focused copies are qualified separately from the full +repository checks in the existing 24-test module. Independent source/metadata +review passed and confirms untouched scanner, categories, unrelated entries, +tests, production, policy and waivers. + +The complete hosted run at `62b661bd8` (identical tested merge tree `de7cafd43`) +finished nine backend lanes successfully. Schema execution completed in 748.049 +seconds within the unchanged 1200-second cap, with 571 ordinary tests passing and +only this stale UUID-inventory test failing. This is not a full-suite pass. A fresh +required hosted run on the published metadata-repair candidate remains required +for merge readiness. Earlier native/image proofs remain explicitly tied to their +recorded heads; this metadata-only correction requires no new runtime proof. + +### Owner-aligned completion-evidence lane capacity + +Lead-authorized bounded scope: this existing record, +`backend/scripts/test_lane_catalogue.py`, and only the literal expected-module +sets in `backend/tests/test_ci_lane_catalogue.py`. Move the whole six-node +`tests/checkers/execution/test_completion_evidence.py` module from TASK selection +to the existing four PROJECT partitions in `CHECKER_DELIVERY_MODULES`. Its native +proofs own current CHECKERS result, material, execution/finalization receipts and +successor currentness, without TASK routing or mutation authority. Preserve every +test body, assertion, PostgreSQL marker, execution kind and selection algorithm. +No production, migration, workflow, timeout, lane-count, exclusion, waiver or +required-check change is allowed. Correct only the accompanying outdated +three-way description of the existing four-way checker partition. + +Authenticated hosted run `38297853598`, tested merge +`d5ba919b2488724027bf0326e148a816a3f6ebe1`, completed all six cases: two on TASK-A +and four on TASK-C. TASK-C completed 294 of 295 nodes before its unchanged +1200-second cap; the last ordinary worker permission-denial test did not finish. +PROJECT A/B/C/D elapsed 736.325/892.426/934.605/995.090 seconds, leaving observed +headroom of 463.675/307.574/265.395/204.910 seconds. TASK-B passed in 1127.335 +seconds and receives no additional margin from this move. The captured TASK-A +foreign-owner case took 9.07 seconds; the interrupted TASK-C run has no complete +per-node duration report. Removing native fixture work is a bounded capacity +repair, not a measured whole-module saving or guarantee of future execution time. + +Risk: missing or duplicating nodes during reassignment, or confusing natural +HEAD-seeded UUID parameter changes with catalogue effects. Acceptance requires +real canonical before/after collection of all 8,776 unique nodes across the same +ten lanes without skips or deselections. Apply both catalogues to one identical +real collected node list to prove exactly six lane changes, 8,770 unrelated lane +assignments unchanged, and all six execution kinds still `ordinary_isolated`. +Cross-head comparisons must qualify the collector's existing HEAD-seeded UUID +parameter values. Existing exact catalogue, exhaustive assignment and negative +partition tests remain mandatory. Lead CI-integrity and independent peer +test-delta review inspect the frozen scope and evidence; a fresh complete hosted +run is required for capacity and merge readiness. No new native proof is needed +because test sources and runtime owners are unchanged. + + +At clean `8219b30bb`, the existing catalogue/runner modules passed all 63 tests +in 19.34 seconds, including exact membership, exhaustive/disjoint selection and +negative partition controls. Actual canonical collection at clean `1eafd2ce4` +and `8219b30bb` each retained 8,776 unique nodes in ten lanes with zero skips or +deselections. Applying both catalogues to one identical actual collected list +changes exactly the six completion-evidence lane assignments and leaves 8,770 +rows unchanged; all six remain ordinary isolated execution. The collector seeds +UUID parameter values from HEAD, so this same-list comparison isolates catalogue +effects. Cross-head comparisons separately normalize only those existing UUID +parameter spellings; no collector or selection algorithm changed. Test source +AST comparison confirms only the expected-module literals were relocated. Ruff +and diff checks pass, and lead independent source/test-delta review found no +issue. Fresh complete hosted execution is still required; the measured capacity +qualification above does not guarantee a green run or authorize merging. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 30db53de5..f8e55a01d 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -214,7 +214,7 @@ jobs: services: redis: - image: public.ecr.aws/docker/library/redis:7-alpine@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7 + image: docker.io/library/redis:7-alpine@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7 ports: - 6380:6379 options: >- @@ -223,7 +223,7 @@ jobs: --health-timeout 5s --health-retries 10 postgres: - image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + image: docker.io/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 env: POSTGRES_DB: workstream_test POSTGRES_USER: workstream @@ -388,7 +388,7 @@ jobs: services: redis: - image: public.ecr.aws/docker/library/redis:7-alpine@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7 + image: docker.io/library/redis:7-alpine@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7 ports: - 6380:6379 options: >- @@ -397,7 +397,7 @@ jobs: --health-timeout 5s --health-retries 10 postgres: - image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + image: docker.io/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 env: POSTGRES_DB: workstream_test POSTGRES_USER: workstream diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 1a394cc87..7db3f5aca 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 15 services: postgres: - image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + image: docker.io/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 env: POSTGRES_DB: postgres POSTGRES_USER: workstream diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml index becb87c7c..d292fac45 100644 --- a/.github/workflows/mcp.yml +++ b/.github/workflows/mcp.yml @@ -103,7 +103,7 @@ jobs: services: postgres: - image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + image: docker.io/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 env: POSTGRES_DB: workstream_test POSTGRES_USER: workstream diff --git a/README.md b/README.md index 3ae293ceb..1236f8768 100644 --- a/README.md +++ b/README.md @@ -177,13 +177,20 @@ durable post-submit execution and unfinished-attempt recovery. ARCH-04D2 supplie exact service authority. ARCH-04E1A adds immutable route-neutral TASK source storage, detached source facts and accepted-effects contracts. B6 commits each new hidden Submission with its initial checker reservation and outbox request; -automatic production delivery, routing and acceptance remain unavailable; hidden -authorized outcomes are implemented. Project-guide ingestion stores original documents, +selected production request/completion delivery now runs digest-pinned external +images after the four Workstream defaults and approved external PRE checks. +Passing true-policy results reach `review_pending`; eligible false-policy results +invoke shared authorized acceptance atomically. Public contributor intake and +false-guide activation remain separate pilot work. Project-guide ingestion stores original documents, records immutable metadata and provides authorized exact-file reads to the unified setup agent. Accepted originals are PDF, DOCX, PPTX and byte-preserved UTF-8 Markdown (`.md`); HTML and documentation sites are not guide inputs. Guide metadata in PostgreSQL also holds at least one required task example; the agent assesses the examples with the uploaded guide documents. + +Default-only projects need no external-service configuration. The fixed Rust +provider is resolved only when a required project image executes; missing +configuration for that image produces recoverable infrastructure evidence. Findings and policy proposals retain document-access evidence. [ART-07A1](.commitrail/initiatives/WS-ART-001/WS-ART-001-07A1.md) provides strict @@ -459,8 +466,9 @@ AUTH/PREP decisions for claim, invocation and finalization. AUTH-OUTBOX-02 binds those decisions to immutable delivery receipts and supplies Celery delivery and bounded recovery scans. ARCH-03C2 registers exact assignment invalidation, with atomic publication from supported authority mutations and separate fixed -feature authority. The authorized checker request and completion handlers are -implemented but unregistered; production registration and live composition remain. +feature authority. The selected checker request and completion handlers are +registered in production composition; public contributor intake remains PILOT-12 +work. Registration does not establish deployed sandbox readiness. Delivery consumes `workstream.outbox` only under a validated non-eager prefork Celery process; other queues can use their appropriate execution pool. @@ -755,10 +763,12 @@ canonical evidence ownership. The obsolete checker worker, manual execution and submission-finalize repair routes are removed. `evaluate_post_submission` now uses hidden durable execution with exact ARCH-04D2 service authority. ARCH-04E1A retains route-neutral source evidence. REV-04C uses its bounded exact-source -verifier and hidden FinalAcceptance/TASK/CON participant, while no general -routing publication writer/reader or current pointer exists. B7 implements hidden +verifier and hidden FinalAcceptance/TASK/CON participant. The authorized TASK +outcome owner publishes exact routing sources under CHECKERS current-run custody. +B7 implements hidden request delivery with invocation fencing. B8 adds hidden completion routing with commit-before-ACK. Production -registration remains ARCH-04E3 work. +request/completion registration is supplied by the selected PILOT-04 composition; +public contributor intake remains PILOT-12 work. Submission and checker history use live exact-project Submitter authority for the original contributor. Separate `/projects/{project_id}` reads require a covering Project Manager grant and expose fixed management fields. Token roles confer no @@ -805,8 +815,8 @@ ARCH-03A completes the existing internal PROJECTS context port. New work selects one active activated guide; existing work resolves its exact frozen guide and policy selectors, including after a successor or contribution-policy retirement. The result includes the saved activation receipt, both checker-policy bodies, -artifact/effective policy, review/revision semantics and recorded catalogue -identities. It never substitutes current policies or reruns inference. CP08 uses +artifact/effective policy, review/revision semantics and exact approved external +image bindings. Retained earlier contexts preserve their immutable identities. It never substitutes current policies or reruns inference. CP08 uses that port in the existing TASK writers: screening stamps the activated contribution policy, claim copies it to the assignment, and hidden Submission creation copies the assignment's exact stamp. PostgreSQL rejects mismatched or replaced stamps. @@ -851,12 +861,14 @@ input with scoped async file access and cleanup. ARCH-04B2 adds hidden typed checker-output storage, byte-free recovery and flush-only verified binding over the generic ART put/verification path. ARCH-04C supplies hidden durable execution, exact request/execution-lease custody, immutable member results and atomic completion events. -The structural catalogue produces no output files, so output write/bind authority -remains unavailable. ARCH-04D1 validates retained terminal material against canonical +Selected image execution retains normalized bounded JSON results. Checker file +output write/bind authority remains unavailable; neither executable catalogue +remains in the live flow. ARCH-04D1 validates retained terminal material against canonical ART lineage. ARCH-04D2 supplies fixed-service input, execute and finalize authority, with PostgreSQL enforcement of exact execution/finalization receipts. ARCH-04E1A -adds immutable source storage and detached contracts only. Automatic dispatch, -production routing and public acceptance remain ARCH-04E work. The hidden +adds immutable source storage and detached contracts. PILOT-04 composes selected +hidden dispatch and production routing; public contributor intake and supported +false-guide activation remain separate pilot work. The hidden [ARCH-04E2-B operation](.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E2B.md) consumes canonical fixed-router authorization and commits the actual receipt with its exact source and complete consequence. Locked true moves TASK to diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 5dbe788ab..9fa00f7a5 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 = "0035_checker_retry" +_CURRENT_HEAD_REVISION = "0036_selected_checker_flow" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_uuid7_v01 migration" @@ -88,6 +88,7 @@ def do_run_migrations(connection: Connection) -> None: ("0032_checker_remediation",), ("0033_contribution_reads",), ("0034_checker_replacement",), + ("0035_checker_retry",), (_CURRENT_HEAD_REVISION,), ): raise RuntimeError(_RECREATE_GUIDANCE) diff --git a/backend/alembic/versions/0036_selected_checker_flow.py b/backend/alembic/versions/0036_selected_checker_flow.py new file mode 100644 index 000000000..ad5e838c9 --- /dev/null +++ b/backend/alembic/versions/0036_selected_checker_flow.py @@ -0,0 +1,1139 @@ +"""Install selected PRE custody and selected POST policy lineage.""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0036_selected_checker_flow" +down_revision = "0035_checker_retry" +branch_labels = None +depends_on = None + + +def _definition(table: str, name: str) -> str: + return op.get_bind().execute(sa.text( + "select pg_catalog.pg_get_constraintdef(oid) from pg_catalog.pg_constraint " + "where conrelid=cast(:table as regclass) and conname=:name" + ), {"table": "public." + table, "name": name}).scalar_one() + + +def _replace(table: str, name: str, definition: str) -> None: + op.execute(f"alter table public.{table} drop constraint {name}") + op.execute(f"alter table public.{table} add constraint {name} {definition}") + + +def _extend(table: str, name: str, anchor: str, addition: str, count: int) -> None: + definition = _definition(table, name) + if definition.count(anchor) != count or addition in definition: + raise RuntimeError(f"selected checker constraint shape changed: {name}") + _replace(table, name, definition.replace(anchor, anchor + addition)) + + +def _extend_authority_vocabulary() -> None: + _extend( + "actor_profiles", "ck_actor_profiles_kind_service_identity", + "('workstream.checker.post_submit'::character varying)::text", + ", ('workstream.checker.pre_submit'::character varying)::text", 1, + ) + _extend( + "audit_events", "ck_audit_events_authority_registries", + "('checker.post_submit.execute'::character varying)::text", + ", ('checker.pre_submit.execute'::character varying)::text, " + "('checker.pre_submit.finalize'::character varying)::text", 1, + ) + _extend( + "audit_events", "ck_audit_events_authority_privacy_bounds", + "('checker_run'::character varying)::text", + ", ('selected_pre_submit_run'::character varying)::text", 1, + ) + name = "ck_audit_events_authorization_action_evidence" + definition = _definition("audit_events", name) + anchor = ( + "(((action_id)::text = 'project.task.create'::text) AND " + "((permission_id)::text = 'project.task.manage'::text))" + ) + additions = " OR ".join( + "(((action_id)::text = " + f"'{action}'::text) AND ((permission_id)::text = '{action}'::text))" + for action in ("checker.pre_submit.execute", "checker.pre_submit.finalize") + ) + if definition.count(anchor) != 2 or additions in definition: + raise RuntimeError("selected PRE audit action constraint shape changed") + _replace("audit_events", name, definition.replace(anchor, anchor + " OR " + additions)) + + +def _create_selected_run_table() -> None: + op.create_table( + "selected_pre_submit_runs", + sa.Column("id", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("actor_profile_id", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("identity_link_id", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("project_id", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("task_id", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("assignment_id", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("idempotency_key", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("prepared_generation_id", sa.Uuid(as_uuid=False), nullable=False), + sa.Column("request_json", postgresql.JSONB(), nullable=False), + sa.Column("request_digest", sa.String(71), nullable=False), + sa.Column("policy_sha256", sa.String(71), nullable=False), + sa.Column("default_behavior_sha256", sa.String(71), nullable=False), + sa.Column("default_input_sha256", sa.String(71), nullable=False), + sa.Column("archive_sha256", sa.String(71), nullable=False), + sa.Column("archive_byte_count", sa.BigInteger(), nullable=False), + sa.Column("semantic_manifest_sha256", sa.String(71), nullable=False), + sa.Column("execution_budget_ms", sa.Integer(), nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("lease_id", sa.Uuid(as_uuid=False)), + sa.Column("lease_generation", sa.Integer(), nullable=False), + sa.Column("lease_expires_at", sa.DateTime(timezone=True)), + sa.Column("execute_evidence_id", sa.Uuid(as_uuid=False)), + sa.Column("finalize_evidence_id", sa.Uuid(as_uuid=False)), + sa.Column("terminal_json", postgresql.JSONB()), + sa.Column("terminal_digest", sa.String(71)), + sa.Column("eligible", sa.Boolean()), + sa.Column("queued_at", sa.DateTime(timezone=True), server_default=sa.text("clock_timestamp()"), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.CheckConstraint("(get_byte(uuid_send(id),6)>>4)=7 and (get_byte(uuid_send(id),8)&192)=128", name="id_uuid7"), + sa.CheckConstraint("status in ('queued','running','completed','infrastructure_failed')", name="status"), + sa.CheckConstraint("request_digest ~ '^sha256:[0-9a-f]{64}$' and policy_sha256 ~ '^sha256:[0-9a-f]{64}$' and default_behavior_sha256 ~ '^sha256:[0-9a-f]{64}$' and default_input_sha256 ~ '^sha256:[0-9a-f]{64}$' and archive_sha256 ~ '^sha256:[0-9a-f]{64}$' and semantic_manifest_sha256 ~ '^sha256:[0-9a-f]{64}$' and (terminal_digest is null or terminal_digest ~ '^sha256:[0-9a-f]{64}$')", name="digests"), + sa.CheckConstraint("archive_byte_count > 0", name="archive_size"), + sa.CheckConstraint("execution_budget_ms between 100 and 14400000", name="execution_budget"), + sa.CheckConstraint("lease_generation >= 0", name="lease_generation"), + sa.CheckConstraint("jsonb_typeof(request_json)='object' and (terminal_json is null or jsonb_typeof(terminal_json)='object')", name="json_objects"), + sa.CheckConstraint( + "(status='queued' and lease_id is null and lease_expires_at is null and terminal_json is null and terminal_digest is null and eligible is null and started_at is null and completed_at is null and execute_evidence_id is null and finalize_evidence_id is null) or " + "(status='running' and lease_id is not null and lease_generation>0 and lease_expires_at is not null and terminal_json is null and terminal_digest is null and eligible is null and started_at is not null and completed_at is null and execute_evidence_id is not null and finalize_evidence_id is null) or " + "(status='completed' and lease_id is not null and lease_generation>0 and lease_expires_at is not null and terminal_json is not null and terminal_digest is not null and eligible is not null and started_at is not null and completed_at is not null and execute_evidence_id is not null and finalize_evidence_id is not null) or " + "(status='infrastructure_failed' and lease_id is not null and lease_generation>0 and lease_expires_at is not null and terminal_json is not null and terminal_digest is not null and eligible is null and started_at is not null and completed_at is not null and execute_evidence_id is not null and finalize_evidence_id is not null)", + name="lifecycle", + ), + sa.ForeignKeyConstraint(["task_id", "project_id"], ["workstream_tasks.id", "workstream_tasks.project_id"], name="fk_selected_pre_run_task_project"), + sa.ForeignKeyConstraint(["assignment_id", "task_id", "actor_profile_id"], ["task_assignments.id", "task_assignments.task_id", "task_assignments.contributor_id"], name="fk_selected_pre_run_assignment"), + sa.ForeignKeyConstraint(["identity_link_id", "actor_profile_id"], ["actor_identity_links.id", "actor_identity_links.actor_profile_id"], name="fk_selected_pre_run_actor_link"), + sa.ForeignKeyConstraint(["execute_evidence_id"], ["audit_events.id"], name="fk_selected_pre_submit_runs_execute_evidence_id_audit_events", ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["finalize_evidence_id"], ["audit_events.id"], name="fk_selected_pre_submit_runs_finalize_evidence_id_audit_events", ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name="pk_selected_pre_submit_runs"), + sa.UniqueConstraint("request_digest", name="request_digest"), + sa.UniqueConstraint("actor_profile_id", "idempotency_key", name="uq_selected_pre_run_actor_key"), + schema="public", + ) + op.create_index("ix_selected_pre_runs_task", "selected_pre_submit_runs", ["task_id"]) + op.create_index("ix_selected_pre_runs_status", "selected_pre_submit_runs", ["status"]) + + +def _alter_selected_lineage() -> None: + for table in ("submissions", "checker_runs"): + op.add_column(table, sa.Column("selected_post_submit_policy_sha256", sa.String(71))) + op.add_column(table, sa.Column("selected_post_submit_policy_body", postgresql.JSONB())) + op.create_check_constraint( + "selected_post_submit_policy", table, + "(selected_post_submit_policy_sha256 is null and selected_post_submit_policy_body is null) or (selected_post_submit_policy_sha256 ~ '^sha256:[0-9a-f]{64}$' and jsonb_typeof(selected_post_submit_policy_body)='object')", + ) + + table = "submission_bundle_durable_intents" + op.alter_column(table, "pre_submit_evidence_set_id", existing_type=sa.Uuid(), nullable=True) + op.add_column(table, sa.Column("selected_pre_submit_run_id", sa.Uuid(as_uuid=False))) + op.add_column(table, sa.Column("selected_request_json", postgresql.JSONB())) + op.add_column(table, sa.Column("selected_request_digest", sa.String(71))) + op.add_column(table, sa.Column("selected_terminal_json", postgresql.JSONB())) + op.add_column(table, sa.Column("selected_terminal_digest", sa.String(71))) + op.create_foreign_key(op.f("fk_submission_bundle_durable_intents_selected_pre_submit_run_id_selected_pre_submit_runs"), table, "selected_pre_submit_runs", ["selected_pre_submit_run_id"], ["id"], ondelete="RESTRICT") + op.create_index(op.f("ix_submission_bundle_durable_intents_selected_pre_submit_run_id"), table, ["selected_pre_submit_run_id"]) + op.create_unique_constraint("uq_submission_bundle_intent_selected_run", table, ["selected_pre_submit_run_id"]) + op.create_check_constraint("pre_submit_lineage", table, "((pre_submit_evidence_set_id is not null)::int + (selected_pre_submit_run_id is not null)::int)=1") + op.create_check_constraint("selected_snapshot", table, "(pre_submit_evidence_set_id is not null and selected_request_json is null and selected_request_digest is null and selected_terminal_json is null and selected_terminal_digest is null) or (selected_pre_submit_run_id is not null and jsonb_typeof(selected_request_json)='object' and selected_request_digest ~ '^sha256:[0-9a-f]{64}$' and jsonb_typeof(selected_terminal_json)='object' and selected_terminal_digest ~ '^sha256:[0-9a-f]{64}$')") + + table = "submission_bundle_admissions" + op.alter_column(table, "pre_submit_evidence_set_id", existing_type=sa.Uuid(), nullable=True) + columns = ( + sa.Column("selected_pre_submit_run_id", sa.Uuid(as_uuid=False)), + sa.Column("guide_id", sa.Uuid(as_uuid=False)), sa.Column("guide_version", sa.String(50)), + sa.Column("source_snapshot_id", sa.Uuid(as_uuid=False)), sa.Column("source_snapshot_sha256", sa.String(71)), + sa.Column("effective_policy_id", sa.Uuid(as_uuid=False)), sa.Column("effective_policy_sha256", sa.String(71)), + sa.Column("pre_submit_policy_id", sa.Uuid(as_uuid=False)), sa.Column("pre_submit_policy_sha256", sa.String(71)), + sa.Column("effective_plan_sha256", sa.String(71)), sa.Column("selected_rules_sha256", sa.String(71)), + sa.Column("selected_post_submit_policy_sha256", sa.String(71)), sa.Column("selected_post_submit_policy_body", postgresql.JSONB()), + sa.Column("packet_sha256", sa.String(71)), sa.Column("semantic_manifest_body", postgresql.JSONB()), + ) + for column in columns: + op.add_column(table, column) + op.create_foreign_key(op.f("fk_submission_bundle_admissions_selected_pre_submit_run_id_selected_pre_submit_runs"), table, "selected_pre_submit_runs", ["selected_pre_submit_run_id"], ["id"], ondelete="RESTRICT") + op.create_index("ix_submission_bundle_admissions_selected_pre_submit_run_id", table, ["selected_pre_submit_run_id"]) + op.create_unique_constraint("uq_submission_bundle_admission_selected_run", table, ["selected_pre_submit_run_id"]) + op.create_check_constraint("pre_submit_lineage", table, "((pre_submit_evidence_set_id is not null)::int + (selected_pre_submit_run_id is not null)::int)=1") + op.create_check_constraint("manifest_body", table, "(selected_pre_submit_run_id is null and semantic_manifest_body is null) or (selected_pre_submit_run_id is not null and jsonb_typeof(semantic_manifest_body)='object')") + for name in ("packet_sha256", "effective_plan_sha256", "selected_rules_sha256"): + op.create_check_constraint(name, table, f"(selected_pre_submit_run_id is null and {name} is null) or (selected_pre_submit_run_id is not null and {name} ~ '^sha256:[0-9a-f]{{64}}$')") + op.create_check_constraint("selected_post_submit_policy", table, "(selected_pre_submit_run_id is null and selected_post_submit_policy_sha256 is null and selected_post_submit_policy_body is null) or (selected_pre_submit_run_id is not null and selected_post_submit_policy_sha256 ~ '^sha256:[0-9a-f]{64}$' and jsonb_typeof(selected_post_submit_policy_body)='object')") + op.create_check_constraint("locked_policy_digests", table, "(selected_pre_submit_run_id is null and guide_id is null and guide_version is null and source_snapshot_id is null and source_snapshot_sha256 is null and effective_policy_id is null and effective_policy_sha256 is null and pre_submit_policy_id is null and pre_submit_policy_sha256 is null) or (selected_pre_submit_run_id is not null and guide_id is not null and octet_length(guide_version) between 1 and 50 and source_snapshot_id is not null and effective_policy_id is not null and pre_submit_policy_id is not null and source_snapshot_sha256 ~ '^sha256:[0-9a-f]{64}$' and effective_policy_sha256 ~ '^sha256:[0-9a-f]{64}$' and pre_submit_policy_sha256 ~ '^sha256:[0-9a-f]{64}$')") + + + +def _replace_admission_guards() -> None: + op.execute(r""" +create or replace function public.guard_submission_bundle_durable_intent_put_attempt() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +declare request_type text; selected_matches integer; +begin + select producer_request_type into request_type from public.artifact_put_attempts + where id=new.put_attempt_id for share; + if request_type is distinct from 'submission_bundle' then + raise exception 'submission bundle durable intent requires submission_bundle put attempt' + using errcode='23514'; + end if; + if new.pre_submit_evidence_set_id is not null + or new.selected_pre_submit_run_id is null then + raise exception 'submission bundle durable intent requires selected PRE lineage' + using errcode='23514'; + end if; + select count(*) into selected_matches + from public.selected_pre_submit_runs r + join public.artifact_put_attempts a on a.id=new.put_attempt_id + where r.id=new.selected_pre_submit_run_id and r.status='completed' and r.eligible + and new.selected_request_json=r.request_json + and new.selected_request_digest=r.request_digest + and new.selected_terminal_json=r.terminal_json + and new.selected_terminal_digest=r.terminal_digest + and a.producer_type='actor_profile' and a.producer_ref=r.actor_profile_id::text + and a.project_id=r.project_id and a.task_id=r.task_id + and a.sha256=r.archive_sha256 and a.byte_count=r.archive_byte_count + and a.media_type='application/zip'; + if selected_matches<>1 then + raise exception 'selected PRE durable intent custody mismatch' using errcode='23514'; + end if; + return new; +end $$ +""") + op.execute(r""" +create or replace function public.guard_submission_bundle_admission_lineage() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +begin + if (to_jsonb(old)-array['status','consumed_at','consumed_by_submission_id', + 'consumed_by_submission_version','stale_at','stale_reason']::text[]) + is distinct from + (to_jsonb(new)-array['status','consumed_at','consumed_by_submission_id', + 'consumed_by_submission_version','stale_at','stale_reason']::text[]) then + raise exception 'submission bundle admission lineage is immutable' using errcode='55000'; + end if; + if old.status<>'ready' or new.status not in ('consumed','stale') then + raise exception 'invalid submission bundle admission transition' using errcode='23514'; + end if; + return new; +end $$ +""") + op.execute(r""" +create or replace function public.guard_submission_bundle_admission_verified_lineage() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +declare matches integer; +begin + if new.pre_submit_evidence_set_id is not null + or new.selected_pre_submit_run_id is null then + raise exception 'submission bundle admission requires selected PRE lineage' + using errcode='23514'; + end if; + select count(*) into matches + from public.submission_bundle_durable_intents intent + join public.selected_pre_submit_runs r on r.id=intent.selected_pre_submit_run_id + join public.artifact_put_attempts attempt on attempt.id=intent.put_attempt_id + join public.artifact_replicas replica on replica.id=attempt.replica_id + join public.artifact_contents content on content.id=replica.content_id + join public.artifact_verification_jobs job + on job.originating_put_attempt_id=attempt.id and job.replica_id=replica.id + join public.artifact_verification_receipts verification on verification.verification_job_id=job.id + where intent.id=new.durable_intent_id and r.id=new.selected_pre_submit_run_id + and r.status='completed' and r.eligible and intent.selected_request_json=r.request_json + and intent.selected_request_digest=r.request_digest + and intent.selected_terminal_json=r.terminal_json and intent.selected_terminal_digest=r.terminal_digest + and attempt.id=new.put_attempt_id and content.id=new.artifact_content_id + and replica.id=new.verified_replica_id and verification.id=new.verification_receipt_id + and attempt.producer_request_type='submission_bundle' + and attempt.producer_type='actor_profile' and attempt.producer_ref=r.actor_profile_id::text + and attempt.project_id=r.project_id and attempt.task_id=r.task_id + and attempt.media_type='application/zip' and content.media_type='application/zip' + and attempt.status='object_confirmed' and replica.verification_state='verified' + and replica.availability_state='available' and replica.integrity_state='valid' + and verification.outcome='verified' and verification.execution_generation=job.execution_generation + and verification.observed_sha256=attempt.sha256 and verification.observed_sha256=content.sha256 + and verification.observed_sha256=r.archive_sha256 + and verification.observed_byte_count=attempt.byte_count + and verification.observed_byte_count=content.byte_count + and verification.observed_byte_count=r.archive_byte_count + and new.actor_profile_id=r.actor_profile_id + and new.identity_link_id=r.identity_link_id and new.project_id=r.project_id + and new.task_id=r.task_id and new.assignment_id=r.assignment_id + and new.predecessor_submission_id is not distinct from + nullif(r.request_json#>>'{admission_lineage,predecessor_submission_id}','')::uuid + and new.predecessor_submission_version is not distinct from + (r.request_json#>>'{admission_lineage,predecessor_submission_version}')::integer + and new.checker_remediation_manifest_id is not distinct from + nullif(r.request_json#>>'{admission_lineage,checker_remediation_manifest_id}','')::uuid + and new.guide_id=(r.request_json#>>'{admission_lineage,guide_id}')::uuid + and new.guide_version=r.request_json#>>'{admission_lineage,guide_version}' + and new.source_snapshot_id=(r.request_json#>>'{admission_lineage,source_snapshot_id}')::uuid + and new.source_snapshot_sha256=r.request_json#>>'{admission_lineage,source_snapshot_sha256}' + and new.effective_policy_id=(r.request_json#>>'{admission_lineage,effective_policy_id}')::uuid + and new.effective_policy_sha256=r.request_json#>>'{admission_lineage,effective_policy_sha256}' + and new.pre_submit_policy_id=(r.request_json#>>'{admission_lineage,pre_submit_policy_id}')::uuid + and new.pre_submit_policy_sha256=r.request_json#>>'{admission_lineage,pre_submit_policy_sha256}' + and new.effective_plan_sha256=r.request_json#>>'{admission_lineage,effective_plan_sha256}' + and new.selected_rules_sha256=r.request_json#>>'{admission_lineage,rules_sha256}' + and new.selected_post_submit_policy_sha256=r.request_json#>>'{admission_lineage,post_submit_policy_sha256}' + and new.selected_post_submit_policy_body=r.request_json#>'{admission_lineage,post_submit_policy_body}' + and new.locked_policy_context_hash=r.request_json#>>'{admission_lineage,locked_policy_context_hash}' + and new.packet_sha256=r.request_json#>>'{admission_lineage,packet_sha256}' + and new.semantic_manifest_id=(r.request_json#>>'{admission_lineage,semantic_manifest_id}')::uuid + and new.semantic_manifest_body=r.request_json#>'{admission_lineage,semantic_manifest_body}' + and new.semantic_manifest_sha256=r.semantic_manifest_sha256 + and new.archive_sha256=r.archive_sha256 and new.archive_byte_count=r.archive_byte_count + and ((new.put_operation_receipt_id is not null and exists( + select 1 from public.artifact_operation_receipts receipt + where receipt.id=new.put_operation_receipt_id and receipt.put_attempt_id=attempt.id + and receipt.replica_id=replica.id and receipt.outcome='stored_pending_verification')) + or (new.put_observation_receipt_id is not null and exists( + select 1 from public.artifact_put_observation_receipts observation + where observation.id=new.put_observation_receipt_id and observation.put_attempt_id=attempt.id + and observation.outcome='observed_confirmed' + and observation.observed_sha256=attempt.sha256 + and observation.observed_byte_count=attempt.byte_count))); + if matches<>1 then + raise exception 'submission bundle admission verified lineage mismatch' using errcode='23514'; + end if; + return new; +end $$ +""") + + +def _retire_legacy_pre_submit_writers() -> None: + op.execute(r""" +create or replace function public.guard_pre_submit_attempt() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +begin + raise exception 'retained pre-submit attempts are immutable' using errcode='55000'; +end $$ +""") + op.execute(r""" +create or replace function public.guard_pre_submit_evidence_set_creation() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +begin + raise exception 'retained pre-submit evidence is immutable' using errcode='55000'; +end $$ +""") + op.execute(r""" +create or replace function public.guard_pre_submit_evidence_result_membership() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +begin + raise exception 'retained pre-submit evidence results are immutable' using errcode='55000'; +end $$ +""") + op.execute(r""" +create or replace function public.require_pre_submit_attempt_evidence() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +begin + raise exception 'retained pre-submit evidence is immutable' using errcode='55000'; +end $$ +""") + op.execute(r""" +create or replace function public.require_pre_submit_result_reconstruction() +returns trigger language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +begin + raise exception 'retained pre-submit evidence results are immutable' using errcode='55000'; +end $$ +""") + + +def _selected_run_guards() -> None: + op.execute(r""" +create function public.guard_selected_pre_submit_run_insert() returns trigger +language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +declare lineage jsonb; predecessor_id uuid; predecessor_version integer; + remediation_id uuid; +begin + lineage := new.request_json->'admission_lineage'; + if new.status is distinct from 'queued' + or new.id is distinct from (new.request_json->>'attempt_id')::uuid + or new.actor_profile_id is distinct from (new.request_json->>'actor_profile_id')::uuid + or new.identity_link_id is distinct from (new.request_json->>'identity_link_id')::uuid + or new.project_id is distinct from (new.request_json->>'project_id')::uuid + or new.task_id is distinct from (new.request_json->>'task_id')::uuid + or new.assignment_id is distinct from (new.request_json->>'assignment_id')::uuid + or new.idempotency_key is distinct from (new.request_json->>'idempotency_key')::uuid + or new.prepared_generation_id is distinct from + (new.request_json->>'prepared_generation_id')::uuid + or new.policy_sha256 is distinct from new.request_json->>'policy_sha256' + or new.default_behavior_sha256 is distinct from + new.request_json->>'default_behavior_sha256' + or new.default_input_sha256 is distinct from new.request_json->>'default_input_sha256' + or new.archive_sha256 is distinct from new.request_json->>'archive_sha256' + or new.archive_byte_count is distinct from + (new.request_json->>'archive_byte_count')::bigint + or new.semantic_manifest_sha256 is distinct from + new.request_json->>'semantic_manifest_sha256' + or new.execution_budget_ms is distinct from + (new.request_json->>'execution_budget_ms')::integer + or new.request_json->>'schema_version' is distinct from + 'workstream.selected_pre_submit_reservation.v1' + or jsonb_typeof(lineage) is distinct from 'object' + or new.request_digest is distinct from public.external_checker_registry_digest(new.request_json) + then raise exception 'selected PRE reservation lineage mismatch' using errcode='23514'; end if; + + predecessor_id := nullif(lineage->>'predecessor_submission_id','')::uuid; + predecessor_version := (lineage->>'predecessor_submission_version')::integer; + remediation_id := nullif(lineage->>'checker_remediation_manifest_id','')::uuid; + + -- Match the application owner order: TASK, assignment, predecessor, CHECKERS. + perform 1 from public.workstream_tasks t + join public.project_guides g + on g.project_id=t.project_id and g.version=t.locked_guide_version + where t.id=new.task_id and t.project_id=new.project_id + and t.assigned_to=new.actor_profile_id + and t.status=case when predecessor_id is null then 'in_progress' else 'needs_revision' end + and g.id=(lineage->>'guide_id')::uuid + and t.locked_guide_version=lineage->>'guide_version' + and t.locked_guide_source_snapshot_id=(lineage->>'source_snapshot_id')::uuid + and t.locked_guide_source_snapshot_hash=lineage->>'source_snapshot_sha256' + and t.locked_effective_project_submission_artifact_policy_id= + (lineage->>'effective_policy_id')::uuid + and t.locked_effective_project_submission_artifact_policy_hash= + lineage->>'effective_policy_sha256' + and t.locked_pre_submit_checker_policy_id=(lineage->>'pre_submit_policy_id')::uuid + and t.locked_pre_submit_checker_bundle_hash=lineage->>'pre_submit_policy_sha256' + for update of t; + if not found then + raise exception 'selected PRE reservation lineage mismatch' using errcode='23514'; + end if; + + perform 1 from public.task_assignments a + where a.id=new.assignment_id and a.task_id=new.task_id + and a.project_id=new.project_id and a.contributor_id=new.actor_profile_id + and a.status='active' and a.released_at is null + and (predecessor_id is null or a.accepted_at is not null) + for update; + if not found then + raise exception 'selected PRE reservation lineage mismatch' using errcode='23514'; + end if; + + if predecessor_id is null then + if predecessor_version is not null or remediation_id is not null + or exists(select 1 from public.submissions s where s.task_id=new.task_id) + then raise exception 'selected PRE reservation lineage mismatch' using errcode='23514'; end if; + else + if predecessor_version is null or remediation_id is null then + raise exception 'selected PRE reservation lineage mismatch' using errcode='23514'; + end if; + perform 1 from public.submissions s + where s.id=predecessor_id and s.task_id=new.task_id + and s.contributor_id=new.actor_profile_id and s.task_assignment_id=new.assignment_id + and s.version=predecessor_version + and not exists(select 1 from public.submissions successor + where successor.task_id=s.task_id and successor.version>s.version) + for update; + if not found then + raise exception 'selected PRE reservation routed cause is not current' using errcode='23514'; + end if; + + if not exists( + select 1 from public.task_post_submit_routing_manifests m + where m.id=remediation_id and m.project_id=new.project_id and m.task_id=new.task_id + and m.assignment_id=new.assignment_id and m.contributor_id=new.actor_profile_id + and m.submission_id=predecessor_id and m.submission_version=predecessor_version + and m.routing_recommendation='needs_revision' + ) then + raise exception 'selected PRE reservation routed cause is foreign' using errcode='23514'; + end if; + + perform 1 from public.checker_submission_fences f + join public.task_post_submit_routing_manifests m + on m.submission_id=f.submission_id and m.checker_run_id=f.current_run_id + where m.id=remediation_id and f.submission_id=predecessor_id + and public.task_routing_outcome_complete(m) + for update of f; + if not found then + raise exception 'selected PRE reservation routed cause is not current' using errcode='23514'; + end if; + end if; + return new; +end $$ +""") + op.execute("create trigger selected_pre_submit_run_insert before insert on public.selected_pre_submit_runs for each row execute function public.guard_selected_pre_submit_run_insert()") + op.execute(r""" +create function public.selected_pre_submit_facts_digest(r public.selected_pre_submit_runs, phase text) +returns text language sql immutable strict set search_path=pg_catalog,public,pg_temp as $$ + select case phase + when 'execute' then 'sha256:'||encode(sha256(convert_to(public.project_guide_projection_canonical_json( + jsonb_build_object('schema_version','workstream.selected_pre_submit_execute.v1', + 'reservation',r.request_json,'lease_id',r.lease_id::text, + 'lease_generation',r.lease_generation,'lease_expires_at',public.outbox_dispatch_utc(r.lease_expires_at))), 'UTF8')),'hex') + when 'finalize' then 'sha256:'||encode(sha256(convert_to(public.project_guide_projection_canonical_json( + jsonb_build_object('schema_version','workstream.selected_pre_submit_finalize.v1', + 'execute_facts_digest',public.selected_pre_submit_facts_digest(r,'execute'), + 'execution',r.terminal_json->'execution', + 'infrastructure_failure_code',r.terminal_json->'infrastructure_failure_code')), 'UTF8')),'hex') + else null end +$$ +""") + op.execute(r""" +create function public.selected_pre_submit_receipt_valid(r public.selected_pre_submit_runs, phase text, require_active boolean) +returns boolean language sql stable set search_path=pg_catalog,public,pg_temp as $$ + select coalesce(exists( + select 1 from public.audit_events a + join public.actor_profiles p on p.id::text=a.actor_id + join public.actor_identity_links l on l.actor_profile_id=p.id + where a.id=case when phase='execute' then r.execute_evidence_id else r.finalize_evidence_id end + and phase in ('execute','finalize') + and a.event_domain='authority' and a.event_type='SensitiveAuthorizationAllowed' + and a.actor_ref_kind='actor_profile' and p.actor_kind='service' + and p.service_identity='workstream.checker.pre_submit' and l.subject_kind='service' + and (not require_active or (p.status='active' and l.status='active')) + and a.action_id='checker.pre_submit.'||phase and a.permission_id=a.action_id + and a.denial_code is null and a.matched_grant_id is null + and a.project_id=r.project_id and a.resource_type='selected_pre_submit_run' + and a.resource_id=r.id::text and a.target_ref_kind='project' + and a.target_ref_id=r.project_id::text and a.request_id=r.id and a.correlation_id=r.id + and a.after_facts::jsonb=jsonb_build_object('allowed',true, + 'resource_context_digest',public.selected_pre_submit_facts_digest(r,phase)) + and (phase='execute' or (r.execute_evidence_id is not null + and r.finalize_evidence_id is distinct from r.execute_evidence_id + and r.status in ('completed','infrastructure_failed'))) + ),false) +$$ +""") + op.execute(r""" +create function public.guard_selected_pre_submit_run() returns trigger +language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +declare current_run public.selected_pre_submit_runs%rowtype; +begin + if tg_op='DELETE' then raise exception 'selected PRE runs are retained' using errcode='55000'; end if; + if tg_op='TRUNCATE' then raise exception 'selected PRE runs cannot be truncated' using errcode='55000'; end if; + if row(old.id,old.actor_profile_id,old.identity_link_id,old.project_id,old.task_id, + old.assignment_id,old.idempotency_key,old.prepared_generation_id,old.request_json, + old.request_digest,old.policy_sha256,old.default_behavior_sha256,old.default_input_sha256, + old.archive_sha256,old.archive_byte_count,old.semantic_manifest_sha256, + old.execution_budget_ms,old.queued_at) + is distinct from + row(new.id,new.actor_profile_id,new.identity_link_id,new.project_id,new.task_id, + new.assignment_id,new.idempotency_key,new.prepared_generation_id,new.request_json, + new.request_digest,new.policy_sha256,new.default_behavior_sha256,new.default_input_sha256, + new.archive_sha256,new.archive_byte_count,new.semantic_manifest_sha256, + new.execution_budget_ms,new.queued_at) + then raise exception 'selected PRE request custody is immutable' using errcode='55000'; end if; + if not ((old.status='queued' and new.status='running' and new.lease_generation=1) + or (old.status='running' and new.status in ('completed','infrastructure_failed') + and new.lease_generation=old.lease_generation)) + then raise exception 'selected PRE lifecycle transition is invalid' using errcode='23514'; end if; + return new; +end $$ +""") + op.execute("create trigger selected_pre_submit_run_update before update on public.selected_pre_submit_runs for each row execute function public.guard_selected_pre_submit_run()") + op.execute("create trigger selected_pre_submit_run_delete before delete on public.selected_pre_submit_runs for each row execute function public.guard_selected_pre_submit_run()") + op.execute("create trigger selected_pre_submit_run_truncate before truncate on public.selected_pre_submit_runs for each statement execute function public.guard_selected_pre_submit_run()") + op.execute(r""" +create function public.guard_selected_pre_submit_receipts() returns trigger +language plpgsql set search_path=pg_catalog,public,pg_temp as $$ +declare current_run public.selected_pre_submit_runs%rowtype; new_execute boolean; new_finalize boolean; +begin + select * into current_run from public.selected_pre_submit_runs where id=new.id; + if not found then raise exception 'selected PRE authority parent is missing' using errcode='23514'; end if; + if tg_op='INSERT' then + new_execute := current_run.execute_evidence_id is not null; + new_finalize := current_run.finalize_evidence_id is not null; + else + new_execute := current_run.execute_evidence_id is distinct from old.execute_evidence_id; + new_finalize := current_run.finalize_evidence_id is distinct from old.finalize_evidence_id; + end if; + if (current_run.execute_evidence_id is not null and not public.selected_pre_submit_receipt_valid(current_run,'execute',new_execute)) + or (current_run.finalize_evidence_id is not null and not public.selected_pre_submit_receipt_valid(current_run,'finalize',new_finalize)) + then raise exception 'selected PRE authority receipt custody mismatch' using errcode='23514'; end if; + return null; +end $$ +""") + op.execute("create constraint trigger selected_pre_submit_receipt_custody after insert or update on public.selected_pre_submit_runs deferrable initially deferred for each row execute function public.guard_selected_pre_submit_receipts()") + + +def _replace_guide_approval_custody() -> None: + op.execute(r""" +create function public.external_checker_locked_policies_valid(value jsonb, expected_hash text) +returns boolean language plpgsql stable strict +set search_path=pg_catalog,public,pg_temp as $$ +declare phase_name text; policy jsonb; binding jsonb; registry jsonb; + registry_row public.external_checker_registry_entries%rowtype; + prior_requirement text; prior_capability text; prior_version text; +begin + if jsonb_typeof(value) is distinct from 'object' + or (select count(*) from jsonb_object_keys(value))<>4 + or value->>'schema_version' is distinct from 'external_checker_policies.v1' + or value->>'policies_sha256' is distinct from expected_hash + or public.guide_proposal_hash(value-'policies_sha256') is distinct from expected_hash then + raise exception 'external checker policy envelope mismatch' using errcode='23514'; + end if; + if jsonb_typeof(value->'pre_submit') is distinct from 'object' + or jsonb_typeof(value->'post_submit') is distinct from 'object' then + raise exception 'external checker phase policy is missing' using errcode='23514'; + end if; + foreach phase_name in array array['pre_submit','post_submit'] loop + policy:=value->phase_name; + if (select count(*) from jsonb_object_keys(policy))<>(case when phase_name='pre_submit' then 4 else 3 end) + or policy->>'schema_version' is distinct from 'external_'||phase_name||'_policy.v1' + or public.guide_proposal_hash(policy-'policy_sha256') is distinct from + policy->>'policy_sha256' + or jsonb_typeof(policy->'entries') is distinct from 'array' then + raise exception 'external checker phase policy mismatch' using errcode='23514'; + end if; + if phase_name='pre_submit' and policy->'workstream_default' is distinct from + jsonb_build_object('checker_id','workstream.default','checker_version','v1', + 'behavior_sha256','sha256:af2c604f792c4456d3b222036c772161b1503eace65ff90c3d3a23d9d348c28f') + then raise exception 'Workstream default checker identity mismatch' using errcode='23514'; + end if; + prior_requirement:=null; prior_capability:=null; prior_version:=null; + for binding in select item from jsonb_array_elements(policy->'entries') item loop + if jsonb_typeof(binding) is distinct from 'object' + or (select count(*) from jsonb_object_keys(binding))<>5 + or binding->>'requirement_id' is null + or binding->>'stage' is distinct from phase_name + or jsonb_typeof(binding->'configuration') is distinct from 'object' + or public.external_checker_registry_digest(binding->'configuration') + is distinct from binding->>'configuration_sha256' then + raise exception 'external checker policy binding mismatch' using errcode='23514'; + end if; + registry:=binding->'registry'; + if jsonb_typeof(registry) is distinct from 'object' + or (select count(*) from jsonb_object_keys(registry))<>14 then + raise exception 'external checker registry value mismatch' using errcode='23514'; + end if; + select * into registry_row from public.external_checker_registry_entries + where id=(registry->>'registry_entry_id')::uuid + and entry_digest=registry->>'entry_digest'; + if not found + or registry_row.capability_id is distinct from registry->>'capability_id' + or registry_row.capability_version is distinct from registry->>'capability_version' + or registry_row.phase is distinct from registry->>'phase' + or registry_row.phase is distinct from phase_name + or registry_row.image_digest is distinct from registry->>'image_digest' + or registry_row.registration_operation_id is distinct from + (registry->>'registration_operation_id')::uuid + or registry_row.registered_by_actor_profile_id is distinct from + (registry->>'registered_by_actor_profile_id')::uuid + or registry_row.authorization_decision_event_id is distinct from + (registry->>'authorization_decision_event_id')::uuid + or registry_row.created_at is distinct from (registry->>'created_at')::timestamptz + or registry->'configuration_schema' is distinct from jsonb_build_object( + 'schema_id',registry_row.configuration_schema_id, + 'schema_version',registry_row.configuration_schema_version, + 'document',registry_row.configuration_schema_document, + 'schema_sha256',registry_row.configuration_schema_sha256) + or registry->'input_schema' is distinct from jsonb_build_object( + 'schema_id',registry_row.input_schema_id, + 'schema_version',registry_row.input_schema_version, + 'document',registry_row.input_schema_document, + 'schema_sha256',registry_row.input_schema_sha256) + or registry->'output_schema' is distinct from jsonb_build_object( + 'schema_id',registry_row.output_schema_id, + 'schema_version',registry_row.output_schema_version, + 'document',registry_row.output_schema_document, + 'schema_sha256',registry_row.output_schema_sha256) + or registry->'resources' is distinct from jsonb_build_object( + 'cpu_millis',registry_row.cpu_millis, + 'memory_bytes',registry_row.memory_bytes, + 'deadline_ms',registry_row.deadline_ms, + 'maximum_output_bytes',registry_row.maximum_output_bytes) + then raise exception 'external checker registry custody mismatch' using errcode='23514'; + end if; + if (prior_requirement,prior_capability,prior_version) is not null + and row(binding->>'requirement_id',registry_row.capability_id, + registry_row.capability_version) + <= row(prior_requirement,prior_capability,prior_version) + then raise exception 'external checker policy order mismatch' using errcode='23514'; + end if; + prior_requirement:=binding->>'requirement_id'; + prior_capability:=registry_row.capability_id; + prior_version:=registry_row.capability_version; + end loop; + end loop; + return true; +exception when sqlstate '23514' then raise; + when others then + raise exception 'external checker policy value is invalid' using errcode='23514'; +end $$ +""") + definition = op.get_bind().execute(sa.text( + "select pg_get_functiondef('public.require_guide_proposal_approval()'::regprocedure)" + )).scalar_one() + declaration = " prior project_guide_proposal_approvals%rowtype;" + if definition.count(declaration) != 1: + raise RuntimeError("guide approval declaration shape changed") + anchor = """ IF a.operation_id IS NULL THEN + RAISE EXCEPTION 'immutable proposal approval operation missing' USING ERRCODE='23514'; + END IF;""" + if definition.count(anchor) != 1: + raise RuntimeError("guide approval operation guard shape changed") + definition = definition.replace( + anchor, + anchor + """ + IF a.effective_pre_submit_plan::jsonb->>'schema_version' IS DISTINCT FROM + 'external_checker_policies.v1' THEN + RAISE EXCEPTION 'external proposal policy schema unavailable' USING ERRCODE='23514'; + END IF;""", + ) + selected_pre = """ SELECT * INTO pre FROM pre_submit_checker_policies WHERE id=a.pre_submit_policy_id;""" + if definition.count(selected_pre) != 1: + raise RuntimeError("guide approval pre policy lookup shape changed") + definition = definition.replace( + selected_pre, + selected_pre + """ + IF pre.compiled_bundle::jsonb IS DISTINCT FROM + a.effective_pre_submit_plan::jsonb->'pre_submit' THEN + RAISE EXCEPTION 'external proposal PRE policy custody mismatch' USING ERRCODE='23514'; + END IF; + IF NOT public.external_checker_locked_policies_valid( + a.effective_pre_submit_plan::jsonb,a.effective_pre_submit_plan_hash) THEN + RAISE EXCEPTION 'external proposal policy custody mismatch' USING ERRCODE='23514'; + END IF;""", + ) + old_hashes = """ OR guide_proposal_hash(pre.compiled_bundle::jsonb) IS DISTINCT FROM pre.compiled_bundle_hash + OR guide_proposal_hash(a.effective_pre_submit_plan::jsonb) IS DISTINCT FROM a.effective_pre_submit_plan_hash""" + new_hashes = """ OR pre.compiled_bundle::jsonb IS DISTINCT FROM + a.effective_pre_submit_plan::jsonb->'pre_submit' + OR NOT public.external_checker_locked_policies_valid( + a.effective_pre_submit_plan::jsonb,a.effective_pre_submit_plan_hash)""" + if definition.count(old_hashes) != 1: + raise RuntimeError("guide approval plan digest shape changed") + definition = definition.replace(old_hashes, new_hashes) + old_plan = """ OR a.effective_pre_submit_plan::jsonb->'lineage' IS DISTINCT FROM jsonb_build_object( + 'project_id',a.project_id,'guide_id',a.guide_id, + 'guide_version',a.target_json->>'guide_version', + 'source_snapshot_id',a.target_json->>'source_snapshot_id', + 'source_snapshot_hash',a.target_json->>'source_snapshot_hash', + 'effective_policy_id',e.id,'effective_policy_hash',e.effective_policy_hash, + 'pre_submit_policy_id',pre.id,'pre_submit_policy_bundle_hash',pre.compiled_bundle_hash) + OR a.effective_pre_submit_plan::jsonb->'catalogue' IS DISTINCT FROM jsonb_build_object( + 'id',a.target_json->>'pre_catalogue_id','version',a.target_json->>'pre_catalogue_version', + 'schema_version',a.target_json->>'pre_catalogue_schema_version', + 'manifest_sha256',a.target_json->>'pre_catalogue_manifest_hash')""" + new_plan = """""" + if definition.count(old_plan) != 1: + raise RuntimeError("guide approval legacy plan binding shape changed") + op.execute(definition.replace(old_plan, new_plan)) + + +def _replace_post_policy_custody() -> None: + definition = op.get_bind().execute(sa.text( + "select pg_get_functiondef('public.require_post_policy_operation()'::regprocedure)" + )).scalar_one() + old_policy = """ OR p.policy_hash IS DISTINCT FROM guide_proposal_hash(p.policy_body::jsonb) + OR p.policy_body->>'catalogue_manifest_sha256' IS DISTINCT FROM t->'proposal'->>'post_catalogue_manifest_hash'""" + new_policy = """ OR p.policy_body->>'schema_version' IS DISTINCT FROM + 'external_post_submit_policy.v1' + OR p.policy_hash IS DISTINCT FROM p.policy_body->>'policy_sha256' + OR p.policy_hash IS DISTINCT FROM guide_proposal_hash( + p.policy_body::jsonb-'policy_sha256') + OR p.policy_body::jsonb IS DISTINCT FROM + a.effective_pre_submit_plan::jsonb->'post_submit'""" + if definition.count(old_policy) != 1: + raise RuntimeError("post policy custody shape changed") + definition = definition.replace(old_policy, new_policy) + old_required = """ IF EXISTS( + SELECT 1 FROM project_guide_compilations c, + jsonb_array_elements(c.canonical_result::jsonb->'post_submit_bindings') binding + WHERE c.id=NEW.compilation_id AND NOT EXISTS( + SELECT 1 FROM jsonb_array_elements(p.policy_body::jsonb->'entries') entry + WHERE entry->>'checker_id'=binding->>'capability_id' + AND entry->>'definition_version'=binding->>'capability_version' + AND entry->>'classification'='project_required' + AND entry->'configuration'=COALESCE((SELECT jsonb_object_agg(parameter->>'name',parameter->'value') + FROM jsonb_array_elements(binding->'parameters') parameter),'{}'::jsonb))) THEN + RAISE EXCEPTION 'post-policy compiled required binding missing' USING ERRCODE='23514'; + END IF;""" + new_required = """ IF EXISTS( + SELECT 1 FROM project_guide_compilations c, + jsonb_array_elements(c.canonical_result::jsonb->'post_submit_bindings') binding + WHERE c.id=NEW.compilation_id AND NOT EXISTS( + SELECT 1 FROM jsonb_array_elements(p.policy_body::jsonb->'entries') entry + WHERE entry->>'requirement_id'=binding->>'requirement_id' + AND entry->>'stage'='post_submit' + AND entry->'registry'->>'capability_id'=binding->>'capability_id' + AND entry->'registry'->>'capability_version'=binding->>'capability_version' + AND entry->'configuration'=COALESCE((SELECT jsonb_object_agg(parameter->>'name',parameter->'value') + FROM jsonb_array_elements(binding->'parameters') parameter),'{}'::jsonb))) THEN + RAISE EXCEPTION 'post-policy compiled required binding missing' USING ERRCODE='23514'; + END IF;""" + old_extra = """ IF EXISTS( + SELECT 1 FROM jsonb_array_elements(p.policy_body::jsonb->'entries') entry + WHERE entry->>'classification' IS DISTINCT FROM 'platform_default' + AND NOT EXISTS( + SELECT 1 FROM project_guide_compilations c, + jsonb_array_elements(c.canonical_result::jsonb->'post_submit_bindings') binding + WHERE c.id=NEW.compilation_id + AND entry->>'checker_id'=binding->>'capability_id' + AND entry->>'definition_version'=binding->>'capability_version' + AND entry->>'classification'='project_required' + AND entry->'configuration'=COALESCE((SELECT jsonb_object_agg(parameter->>'name',parameter->'value') + FROM jsonb_array_elements(binding->'parameters') parameter),'{}'::jsonb))) THEN + RAISE EXCEPTION 'post-policy compiled selection not requested' USING ERRCODE='23514'; + END IF;""" + new_extra = """ IF EXISTS( + SELECT 1 FROM jsonb_array_elements(p.policy_body::jsonb->'entries') entry + WHERE NOT EXISTS( + SELECT 1 FROM project_guide_compilations c, + jsonb_array_elements(c.canonical_result::jsonb->'post_submit_bindings') binding + WHERE c.id=NEW.compilation_id + AND entry->>'requirement_id'=binding->>'requirement_id' + AND entry->>'stage'='post_submit' + AND entry->'registry'->>'capability_id'=binding->>'capability_id' + AND entry->'registry'->>'capability_version'=binding->>'capability_version' + AND entry->'configuration'=COALESCE((SELECT jsonb_object_agg(parameter->>'name',parameter->'value') + FROM jsonb_array_elements(binding->'parameters') parameter),'{}'::jsonb))) THEN + RAISE EXCEPTION 'post-policy compiled selection not requested' USING ERRCODE='23514'; + END IF;""" + if definition.count(old_required) != 1 or definition.count(old_extra) != 1: + raise RuntimeError("post policy binding custody shape changed") + op.execute(definition.replace(old_required, new_required).replace(old_extra, new_extra)) + + +def _replace_checker_run_custody() -> None: + definition = op.get_bind().execute(sa.text( + "select pg_get_functiondef('public.protect_checker_run_custody()'::regprocedure)" + )).scalar_one() + declaration = " DECLARE body jsonb; source submissions; mutable text[];" + if definition.count(declaration) != 1: + raise RuntimeError("checker run custody declaration shape changed") + definition = definition.replace( + declaration, + " DECLARE body jsonb; source submissions; mutable text[];", + ) + body_anchor = " body := NEW.request_json::jsonb;" + if definition.count(body_anchor) != 1: + raise RuntimeError("checker run request body shape changed") + definition = definition.replace( + body_anchor, + body_anchor + "\n IF body->>'schema_version' IS DISTINCT FROM " + "'workstream.selected_post_submit_request.v1' THEN\n" + " RAISE EXCEPTION 'checker request schema unavailable' " + "USING ERRCODE='23514';\n END IF;", + ) + old_policy = """ OR source.locked_post_submit_checker_policy_body::jsonb IS DISTINCT FROM body->'policy' + OR body->>'project_id' IS DISTINCT FROM NEW.project_id::text""" + new_policy = """ OR source.selected_post_submit_policy_body::jsonb IS DISTINCT FROM body->'policy' + OR source.selected_post_submit_policy_sha256 IS DISTINCT FROM + body#>>'{expected_context,post_policy_hash}' + OR NEW.selected_post_submit_policy_body::jsonb IS DISTINCT FROM body->'policy' + OR NEW.selected_post_submit_policy_sha256 IS DISTINCT FROM + body#>>'{expected_context,post_policy_hash}' + OR body->>'project_id' IS DISTINCT FROM NEW.project_id::text""" + if definition.count(old_policy) != 1: + raise RuntimeError("checker run policy body shape changed") + definition = definition.replace(old_policy, new_policy) + old_context = """ OR body#>>'{expected_context,post_policy_id}' IS DISTINCT FROM NEW.locked_post_submit_checker_policy_id::text + OR body#>>'{expected_context,post_policy_version}' IS DISTINCT FROM NEW.locked_post_submit_checker_policy_version + OR body#>>'{expected_context,post_policy_hash}' IS DISTINCT FROM NEW.locked_post_submit_checker_policy_hash""" + new_context = """ OR body#>>'{expected_context,post_policy_id}' IS DISTINCT FROM + NEW.locked_post_submit_checker_policy_id::text + OR body#>>'{expected_context,post_policy_version}' IS DISTINCT FROM + NEW.locked_post_submit_checker_policy_version + OR body#>>'{expected_context,post_policy_hash}' IS DISTINCT FROM + NEW.locked_post_submit_checker_policy_hash""" + if definition.count(old_context) != 1: + raise RuntimeError("checker run locked context shape changed") + op.execute(definition.replace(old_context, new_context)) + + +def _replace_submission_dispatch_custody() -> None: + definition = op.get_bind().execute(sa.text( + "select pg_get_functiondef('public.submission_dispatch_valid(public.submission_dispatches)'::regprocedure)" + )).scalar_one() + old_packet = """ AND (r.request_json::jsonb->'structural_input'->>'summary')=s.summary + AND (r.request_json::jsonb->'structural_input'->>'worker_attestation')=s.worker_attestation""" + new_packet = """ AND r.request_json::jsonb->>'schema_version' IS NOT DISTINCT FROM + 'workstream.selected_post_submit_request.v1' + AND r.selected_post_submit_policy_sha256=s.selected_post_submit_policy_sha256 + AND r.selected_post_submit_policy_body::jsonb=s.selected_post_submit_policy_body::jsonb + AND r.request_json::jsonb->'policy'=s.selected_post_submit_policy_body::jsonb""" + if definition.count(old_packet) != 1: + raise RuntimeError("submission dispatch packet custody shape changed") + definition = definition.replace(old_packet, new_packet) + old_evidence_join = ( + "JOIN public.pre_submit_evidence_sets evidence " + "ON evidence.id = a.pre_submit_evidence_set_id" + ) + if old_evidence_join not in definition: + old_evidence_join = old_evidence_join.replace(" = ", "=") + if definition.count(old_evidence_join) != 1: + raise RuntimeError("submission dispatch evidence join shape changed") + definition = definition.replace( + old_evidence_join, + old_evidence_join.replace("JOIN public", "LEFT JOIN public"), + ) + old_evidence_cause = ( + "evidence.checker_remediation_manifest_id IS NOT DISTINCT FROM " + "a.checker_remediation_manifest_id" + ) + if definition.count(old_evidence_cause) != 1: + raise RuntimeError("submission dispatch evidence cause shape changed") + new_evidence_cause = """(a.selected_pre_submit_run_id IS NOT NULL + AND a.pre_submit_evidence_set_id IS NULL + AND evidence.id IS NULL)""" + op.execute(definition.replace(old_evidence_cause, new_evidence_cause)) + + +def _replace_submission_binding_resource() -> None: + op.execute(""" +CREATE OR REPLACE FUNCTION public.submission_binding_resource(admission_id uuid) RETURNS jsonb +LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ + SELECT pg_catalog.jsonb_build_object( + 'resource_type','submission_binding','resource_id',a.id,'admission_id',a.id, + 'evidence_set_id',a.pre_submit_evidence_set_id, + 'selected_pre_submit_run_id',a.selected_pre_submit_run_id, + 'actor_profile_id',a.actor_profile_id,'identity_link_id',a.identity_link_id, + 'project_id',a.project_id,'task_id',a.task_id,'assignment_id',a.assignment_id, + 'predecessor_submission_id',a.predecessor_submission_id, + 'predecessor_submission_version',a.predecessor_submission_version, + 'checker_remediation_manifest_id',a.checker_remediation_manifest_id, + 'submission_id',a.consumed_by_submission_id, + 'submission_version',a.consumed_by_submission_version, + 'guide_id',a.guide_id,'guide_version',a.guide_version, + 'source_snapshot_id',a.source_snapshot_id, + 'source_snapshot_sha256',a.source_snapshot_sha256, + 'effective_policy_id',a.effective_policy_id, + 'effective_policy_sha256',a.effective_policy_sha256, + 'pre_submit_policy_id',a.pre_submit_policy_id, + 'pre_submit_policy_sha256',a.pre_submit_policy_sha256, + 'locked_policy_context_hash',a.locked_policy_context_hash, + 'semantic_manifest_id',a.semantic_manifest_id, + 'semantic_manifest_sha256',a.semantic_manifest_sha256, + 'content_id',a.artifact_content_id,'sha256',a.archive_sha256, + 'byte_count',a.archive_byte_count,'logical_role','submission_bundle_original' + ) FROM public.submission_bundle_admissions a + WHERE a.id=$1 AND a.status='consumed' +$$; +""") + + +def _replace_checker_input_authority_digest() -> None: + definition = op.get_bind().execute(sa.text( + "select pg_get_functiondef(" + "'public.checker_input_authority_digest(public.checker_runs)'::regprocedure)" + )).scalar_one() + old_evidence = "'evidence_id', a.pre_submit_evidence_set_id::text" + if old_evidence not in definition: + old_evidence = "'evidence_id',a.pre_submit_evidence_set_id::text" + new_evidence = ( + "'evidence_id', coalesce(a.pre_submit_evidence_set_id, " + "a.selected_pre_submit_run_id)::text" + ) + if definition.count(old_evidence) != 1: + raise RuntimeError("checker input evidence identity shape changed") + op.execute(definition.replace(old_evidence, new_evidence)) + + +def _replace_checker_result_custody() -> None: + op.execute(r""" +CREATE OR REPLACE FUNCTION public.protect_checker_result_custody() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ + DECLARE parent public.checker_runs; entry jsonb; + BEGIN + IF TG_OP <> 'INSERT' THEN + RAISE EXCEPTION 'checker result custody is immutable' USING ERRCODE='23514'; + END IF; + SELECT * INTO parent FROM public.checker_runs + WHERE id=NEW.checker_run_id AND task_id=NEW.task_id + AND submission_id=NEW.submission_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'checker result violates fk_checker_results_run_ownership' + USING ERRCODE='23503', CONSTRAINT='fk_checker_results_run_ownership', + TABLE='checker_results'; + END IF; + IF parent.status IS DISTINCT FROM 'running' OR parent.completed_at IS NOT NULL THEN + RAISE EXCEPTION 'finished or unclaimed checker run cannot receive results' + USING ERRCODE='23514'; + END IF; + entry := parent.request_json::jsonb #> + ARRAY['policy','entries',NEW.member_order::text]; + IF parent.request_json::jsonb->>'schema_version' IS DISTINCT FROM + 'workstream.selected_post_submit_request.v1' THEN + RAISE EXCEPTION 'checker request schema unavailable' USING ERRCODE='23514'; + END IF; + IF entry->>'requirement_id' IS DISTINCT FROM NEW.checker_name + OR entry#>>'{registry,entry_digest}' IS DISTINCT FROM NEW.implementation_version + OR NEW.definition_version IS DISTINCT FROM 'external_checker_result.v1' + OR NEW.status NOT IN ('passed','failed') + OR NEW.failure_category IS DISTINCT FROM + (CASE WHEN NEW.status='passed' THEN 'none' ELSE 'submission_structure' END) + OR NEW.severity IS DISTINCT FROM + (CASE WHEN NEW.status='passed' THEN 'info' ELSE 'error' END) + OR (NEW.status='passed' AND NEW.code IS DISTINCT FROM 'passed') + OR jsonb_typeof(NEW.counters::jsonb) IS DISTINCT FROM 'array' + OR jsonb_array_length(NEW.counters::jsonb)<>0 THEN + RAISE EXCEPTION 'checker member differs from selected definition' + USING ERRCODE='23514'; + END IF; + RETURN NEW; + END +$$; +""") + + +def _replace_checker_terminal_custody() -> None: + definition = op.get_bind().execute(sa.text( + "select pg_get_functiondef('public.validate_checker_terminal_custody()'::regprocedure)" + )).scalar_one() + declaration = ( + " passed integer; warning integer; failed integer; blocking integer; " + "setup integer; envelope jsonb;" + ) + if declaration not in definition: + declaration = ( + " passed integer; warning integer; failed integer; blocking integer; " + "setup integer; envelope jsonb;" + ) + if definition.count(declaration) != 1: + raise RuntimeError("checker terminal declaration shape changed") + definition = definition.replace( + declaration, + declaration.replace(" blocking integer; setup integer;", " blocking integer;") + .replace( + "envelope jsonb;", + "envelope jsonb; expected_members jsonb; " + "expected_execution_members jsonb; actual_execution_members jsonb;", + ), + ) + old_aggregate = """ count(*) FILTER(WHERE status='passed'), count(*) FILTER(WHERE status='warning'), count(*) FILTER(WHERE status='failed'), + count(*) FILTER(WHERE status<>'passed' AND current_run.request_json::jsonb #> '{policy,blocking_severities}' ? severity), + count(*) FILTER(WHERE status<>'passed' AND failure_category='task_configuration' + AND current_run.request_json::jsonb #> '{policy,blocking_severities}' ? severity) + INTO members,passed,warning,failed,blocking,setup FROM checker_results WHERE checker_run_id=current_run.id;""" + selected_aggregate = """ count(*) FILTER(WHERE status='passed'), count(*) FILTER(WHERE status='warning'), + count(*) FILTER(WHERE status='failed') + INTO members,passed,warning,failed FROM checker_results WHERE checker_run_id=current_run.id;""" + if definition.count(old_aggregate) != 1: + raise RuntimeError("checker terminal aggregate shape changed") + definition = definition.replace(old_aggregate, selected_aggregate) + old_route = """ expected_route := CASE WHEN setup>0 THEN 'task_setup_blocked' WHEN blocking>0 THEN 'needs_revision' ELSE 'allow_review' END;""" + selected_route = """ expected_route := CASE WHEN failed>0 THEN 'needs_revision' ELSE 'allow_review' END;""" + if definition.count(old_route) != 1: + raise RuntimeError("checker terminal route shape changed") + definition = definition.replace(old_route, selected_route) + mismatch = """ IF result->'member_results' IS DISTINCT FROM members OR current_run.passed_count IS DISTINCT FROM passed + OR current_run.warning_count IS DISTINCT FROM warning OR current_run.failed_count IS DISTINCT FROM failed + OR current_run.blocking_count IS DISTINCT FROM blocking THEN + RAISE EXCEPTION 'checker terminal members differ' USING ERRCODE='23514'; + END IF;""" + if mismatch not in definition: + mismatch = mismatch.replace(" IF", " IF").replace(" OR", " OR") + selected_check = r""" + IF result->>'schema_version' IS NOT DISTINCT FROM + 'workstream.selected_post_submit_result.v1' + AND result->>'outcome'='completed' THEN + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'position',(entry.ordinality-1)::integer, + 'order',(entry.ordinality-1)::integer, + 'requirement_id',entry.item->>'requirement_id', + 'registry_entry_id',entry.item#>>'{registry,registry_entry_id}', + 'registry_entry_digest',entry.item#>>'{registry,entry_digest}', + 'result_registry_entry_id',entry.item#>>'{registry,registry_entry_id}', + 'result_registry_entry_digest',entry.item#>>'{registry,entry_digest}', + 'result_phase','post_submit', + 'platform_manifest_digest',entry.item#>>'{registry,image_digest}' + ) ORDER BY entry.ordinality),'[]'::jsonb) + INTO expected_execution_members + FROM jsonb_array_elements(current_run.request_json::jsonb#>'{policy,entries}') + WITH ORDINALITY entry(item,ordinality); + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'position',(actual.ordinality-1)::integer, + 'order',(actual.item->>'order')::integer, + 'requirement_id',actual.item->>'requirement_id', + 'registry_entry_id',actual.item->>'registry_entry_id', + 'registry_entry_digest',actual.item->>'registry_entry_digest', + 'result_registry_entry_id',actual.item#>>'{result,registry_entry_id}', + 'result_registry_entry_digest',actual.item#>>'{result,registry_entry_digest}', + 'result_phase',actual.item#>>'{result,phase}', + 'platform_manifest_digest',actual.item#>>'{isolation,platform_manifest_digest}' + ) ORDER BY actual.ordinality),'[]'::jsonb) + INTO actual_execution_members + FROM jsonb_array_elements(result#>'{execution,external_members}') + WITH ORDINALITY actual(item,ordinality); + IF result#>>'{execution,policy_sha256}' IS DISTINCT FROM + current_run.request_json::jsonb#>>'{policy,policy_sha256}' + OR (result#>>'{execution,required_external_count}')::integer IS DISTINCT FROM + jsonb_array_length(current_run.request_json::jsonb#>'{policy,entries}') + OR actual_execution_members IS DISTINCT FROM expected_execution_members THEN + RAISE EXCEPTION 'checker terminal selected policy differs' + USING ERRCODE='23514'; + END IF; + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'schema_version','post_submit_structural_result', + 'checker_id',item->>'requirement_id', + 'definition_version',item#>>'{result,schema_version}', + 'implementation_version',item->>'registry_entry_digest', + 'status',item#>>'{result,verdict}', + 'code',CASE WHEN item#>>'{result,verdict}'='passed' THEN 'passed' + ELSE coalesce(item#>>'{result,findings,0,code}','invalid_output') END, + 'failure_category',CASE WHEN item#>>'{result,verdict}'='passed' + THEN 'none' ELSE 'submission_structure' END, + 'severity',CASE WHEN item#>>'{result,verdict}'='passed' + THEN 'info' ELSE 'error' END, + 'counters','[]'::jsonb + ) ORDER BY (item->>'order')::integer),'[]'::jsonb) + INTO expected_members + FROM jsonb_array_elements(result#>'{execution,external_members}') item; + blocking := failed; + IF members IS DISTINCT FROM expected_members + OR current_run.passed_count IS DISTINCT FROM passed + OR current_run.warning_count IS DISTINCT FROM warning + OR current_run.failed_count IS DISTINCT FROM failed + OR current_run.blocking_count IS DISTINCT FROM blocking THEN + RAISE EXCEPTION 'checker terminal members differ' USING ERRCODE='23514'; + END IF; + END IF; +""" + replacement = selected_check + """ + IF result->>'schema_version' IS DISTINCT FROM + 'workstream.selected_post_submit_result.v1' THEN + RAISE EXCEPTION 'checker result schema unavailable' USING ERRCODE='23514'; + END IF; +""" + if definition.count(mismatch) != 1: + raise RuntimeError("checker terminal member comparison shape changed") + op.execute(definition.replace(mismatch, replacement)) + + +def _replace_submission_packet_custody() -> None: + definition = op.get_bind().execute(sa.text( + "select pg_get_functiondef('public.validate_submission_packet_custody()'::regprocedure)" + )).scalar_one() + old_lookup = """ SELECT evidence.packet_sha256 INTO expected_packet + FROM public.submission_bundle_admissions AS admission + JOIN public.pre_submit_evidence_sets AS evidence + ON evidence.id = admission.pre_submit_evidence_set_id + WHERE admission.id = stored.submission_bundle_admission_id;""" + new_lookup = """ SELECT coalesce(evidence.packet_sha256,admission.packet_sha256) + INTO expected_packet + FROM public.submission_bundle_admissions AS admission + LEFT JOIN public.pre_submit_evidence_sets AS evidence + ON evidence.id = admission.pre_submit_evidence_set_id + WHERE admission.id = stored.submission_bundle_admission_id;""" + if definition.count(old_lookup) != 1: + raise RuntimeError("submission packet custody lookup shape changed") + op.execute(definition.replace(old_lookup, new_lookup)) + + +def upgrade() -> None: + op.execute("set local search_path=pg_catalog,public,pg_temp") + op.execute("lock table public.actor_profiles, public.actor_identity_links, public.audit_events, public.submissions, public.checker_runs, public.submission_bundle_durable_intents, public.submission_bundle_admissions in access exclusive mode") + _extend_authority_vocabulary() + _create_selected_run_table() + _alter_selected_lineage() + _replace_admission_guards() + _retire_legacy_pre_submit_writers() + _selected_run_guards() + _replace_guide_approval_custody() + _replace_post_policy_custody() + _replace_checker_run_custody() + _replace_submission_binding_resource() + _replace_submission_dispatch_custody() + _replace_submission_packet_custody() + _replace_checker_input_authority_digest() + _replace_checker_result_custody() + _replace_checker_terminal_custody() + + +def downgrade() -> None: + raise RuntimeError("Workstream v0.1 migrations cannot be downgraded; recreate the database") diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index f12ed22b6..9b335da35 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -12,10 +12,10 @@ from app.core.config import Settings from app.core.api_controls import request_ids from app.api.deps.authorization import get_authorization_actor_identity -from app.adapters.checkers import PreSubmitCheckerExecutionAdapter, post_submission_executor, checker_output_reservations +from app.adapters.checkers import checker_output_reservations from app.adapters.projects import project_locked_policy_context_port from app.adapters.tasks import task_submission_context_port -from app.db.session import get_db_session, get_session_factory +from app.db.session import get_db_session from app.interfaces.artifact_operations import GuideArtifactIngestCommand from app.modules.projects.api.guide_documents import GuideDocumentUploadTargetPort from app.modules.artifacts.api import SubmissionBundlePreparationCommand @@ -61,54 +61,6 @@ from app.modules.tasks.api.guide_documents import TaskGuideDocumentsPort -from app.modules.artifacts.api import SubmissionBundlePreparationRequest -from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidencePersistenceResult -from app.modules.artifacts.submission_materialization import ( - PreparedBundleMaterializationRequest, - PreparedBundlePreSubmitEvidenceService, -) -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, - PostSubmissionExecutionPort, -) - - -class CheckerPhaseService: - """One command per phase, composed from the canonical ART and CHECKER owners.""" - - def __init__( - self, *, pre_submission: PreparedBundlePreSubmitEvidenceService, - post_submission: PostSubmissionExecutionPort, - ) -> None: - self._pre_submission = pre_submission - self._post_submission = post_submission - - async def evaluate_pre_submission( - self, request: PreparedBundleMaterializationRequest, reservation: object, *, - preparation_request: SubmissionBundlePreparationRequest, - ) -> PreSubmitEvidencePersistenceResult: - """Finish ART's committed selection, preserving its exact canonical result.""" - if request.prepared_authorization is not None: - raise ValueError("pre_submission_phase_requires_consumed_authorization") - if isinstance(reservation, PreSubmitEvidencePersistenceResult): - return reservation - return await self._pre_submission.execute_reserved( - request, reservation, preparation_request=preparation_request, - ) - - async def evaluate_post_submission( - self, request: PostSubmissionEvaluationRequest, - ) -> PostSubmissionEvaluationResult: - """Delegate the closed post contract; production execution remains unavailable.""" - request = PostSubmissionEvaluationRequest.model_validate(request) - result = PostSubmissionEvaluationResult.model_validate( - await self._post_submission.evaluate_post_submission(request) - ) - result.validate_request(request) - return result - - async def get_submission_bundle_preparation_actor( actor: Annotated[ ActorIdentityFacts, @@ -383,7 +335,6 @@ def get_submission_bundle_preparation_command( from app.modules.artifacts.submission_archive import SubmissionArchiveInspector from app.modules.artifacts.submission_materialization import ( PreparedBundleMaterializationService, - PreparedBundlePreSubmitEvidenceService, ) from app.modules.artifacts.submission_admission import ( PreparedSubmissionBundlePreparationCommand, @@ -422,10 +373,16 @@ async def runtime(): ) ) preparation = ArtifactPreparationService(manager) - catalogue = request.app.state.pre_submission_checker_catalogue inspector = SubmissionArchiveInspector(submission_archive_limits(settings)) - checker_execution = PreSubmitCheckerExecutionAdapter( - catalogue=catalogue, + from app.adapters.auth import selected_pre_submit_authority + from app.adapters.checkers import selected_pre_submit_checker_execution + from app.modules.artifacts.selected_pre_submit_execution import ( + SelectedPreSubmitExecutionService, + ) + from app.adapters.checkers import selected_pre_submit_coordinator + + checker_execution = selected_pre_submit_checker_execution( + settings=settings, archive_inspector=inspector, ) storage_schemes = {"local": "local", "s3_compatible": "s3"} @@ -448,28 +405,22 @@ async def runtime(): settings, internal_authority, ) - evidence = PreparedBundlePreSubmitEvidenceService( - session=session, - materialization=materialization, - preparation_authorization=authority, - task_contexts=task_contexts, - project_contexts=project_contexts, - ) - from app.adapters.checkers import submission_evaluation_content + selected_coordinator = selected_pre_submit_coordinator(session) yield SubmissionBundlePreparationRuntime( - evaluation_content=submission_evaluation_content, preparation=preparation, inspector=inspector, - catalogue=catalogue, materialization=materialization, - evidence=evidence, - checker_service=CheckerPhaseService( - pre_submission=evidence, - post_submission=post_submission_executor( - sessions=get_session_factory(), materialization=post_submission_materialization( - sessions=get_session_factory(), store=store, namespace=namespace, - preparation=preparation, inspector=inspector)), + selected_coordinator=selected_coordinator, + selected_execution=SelectedPreSubmitExecutionService( + session=session, + coordinator=selected_coordinator, + authority=selected_pre_submit_authority(session), + materialization=materialization, + ), + storage_scheme=storage_scheme, + artifact_deadline_ms=int( + settings.artifact_preparation_total_deadline_seconds * 1000 ), durable_put=SubmissionBundleDurablePutService( session=session, @@ -576,8 +527,26 @@ async def materialize( initialize_artifact_internal_runtime, ) from app.modules.artifacts.submission_archive import SubmissionArchiveInspector + from app.modules.checkers.api.external_policy import ( + ExternalCheckerPolicyError, + selected_post_submit_execution_budget_ms, + ) + from app.modules.checkers.api.materialization import ( + PostSubmissionMaterializationFailure, + ) settings = get_settings() + try: + selected_post_submit_execution_budget_ms( + facts.request.policy, + artifact_deadline_ms=int( + settings.artifact_preparation_total_deadline_seconds * 1000 + ), + ) + except ExternalCheckerPolicyError: + raise PostSubmissionMaterializationFailure( + "post_submit_material_deadline_unavailable" + ) from None await initialize_artifact_internal_runtime() with _artifact_internal_runtime() as (store, namespace): manager = create_artifact_scratch_manager(settings) diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index bcb463e0b..be1903203 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -280,6 +280,15 @@ def post_submit_execution_authority(session): return PostSubmitExecutionAuthorization(session) +def selected_pre_submit_authority(session): + """Compose fixed selected-PRE execute/finalize authority.""" + from app.modules.authorization.pre_submit_authorization import ( + SelectedPreSubmitAuthorization, + ) + + return SelectedPreSubmitAuthorization(session) + + def post_submit_materialization_authority(session): """Compose the separate fixed ART materializer principal.""" from app.modules.authorization.post_submit_authorization import PostSubmitMaterializationAuthorization diff --git a/backend/app/adapters/checkers/__init__.py b/backend/app/adapters/checkers/__init__.py index d6f5a97af..fcadc105f 100644 --- a/backend/app/adapters/checkers/__init__.py +++ b/backend/app/adapters/checkers/__init__.py @@ -1,53 +1,55 @@ """CHECKER-owned composition adapters.""" -import asyncio -import json from app.adapters.checkers.external_service import ( EXTERNAL_CHECKER_CAPABILITY_KEY, EXTERNAL_CHECKER_PROVIDER_KEY, UnixSocketExternalCheckerAdapter, ) -from app.interfaces.external_services import ExternalServiceAdapterFactory -from app.modules.artifacts.api import SubmissionBundleFile +from app.interfaces.external_checker_execution import ( + ExternalCheckerExecutionAdapter, + ExternalCheckerExecutionResponse, + ExternalCheckerMaterialGrant, + ExternalCheckerServiceHealth, +) +from app.interfaces.external_services import ( + ExternalServiceAdapterFactory, + ExternalServiceAdapterIdentity, + ExternalServiceUnavailableError, +) from app.modules.tasks.api import TaskSubmissionContextFacts -from app.modules.projects.api import ProjectLockedPolicyContextFacts -from app.modules.checkers.api import SubmissionPacketView -from app.modules.checkers.api.artifact_paths import required_evidence_path -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationContent, ExpectedPostSubmitContext, ObservedPostSubmitContext, - PostSubmitPolicyInputs, PostSubmissionStructuralInput, PostSubmitManifestEntry, PostSubmitEvidenceEntry, + +from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy +from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmissionContent, + SelectedPostSubmitLockedContext, ) from app.modules.checkers.api.history import CheckerHistoryReadPort from app.modules.checkers.api.execution import EvaluationCoordinationPort -from typing import Any, BinaryIO, Protocol +from typing import Any, Protocol from pathlib import Path -from app.modules.checkers.api.pre_submit_catalogue import PreSubmissionCapabilityProjection -from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue -from app.modules.checkers.api.policy_compilation import PreSubmissionPolicyCompilationPort - -from app.modules.checkers.api import ( - PreSubmissionExecutionFacts, - PreSubmissionInfrastructureUnavailableError, +from app.modules.checkers.api.external import ( + ExternalCheckerCapabilityDefinition, + ExternalCheckerCapabilityProjection, + ExternalCheckerExecutionRequest, + PreSubmitExternalCheckerIdentity, + PreSubmitExternalCheckerMaterial, + external_checker_json_hash, ) -from app.modules.checkers.catalogue import ( - PreSubmissionCheckerCatalogue, - project_guide_pre_submission_capabilities, - build_pre_submission_checker_catalogue, - parse_disabled_pre_submission_checker_ids, +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_CHECKER_ID, + WORKSTREAM_DEFAULT_CHECKER_VERSION, ) -from app.modules.checkers.pre_submit_execution import ( - DefaultPreSubmissionExecutionError, - DefaultPreSubmissionExecutionInput, - EffectivePreSubmissionProcessor, +from app.modules.checkers.selected_pre_submit_execution import SelectedPreSubmitProcessor +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitInfrastructureUnavailable, ) def external_checker_execution_factory( *, socket_path: Path, timeout_seconds: float ) -> ExternalServiceAdapterFactory[UnixSocketExternalCheckerAdapter]: - """Compose the unselected instance-local external checker adapter factory.""" + """Compose the sole instance-local external checker adapter factory.""" factory = ExternalServiceAdapterFactory[UnixSocketExternalCheckerAdapter]( EXTERNAL_CHECKER_CAPABILITY_KEY ) @@ -62,7 +64,7 @@ def external_checker_execution_factory( def configured_external_checker_execution_factory(settings): - """Map validated settings into the unselected explicit adapter factory.""" + """Map configured image-execution settings into the sole adapter factory.""" if ( settings.external_checker_service_socket is None or settings.external_checker_material_root is None @@ -74,92 +76,193 @@ def configured_external_checker_execution_factory(settings): ) -class _ExecutionRequest(Protocol): - plan: Any - commitment: Any - inspection: Any - manifest: Any - change_gate: Any - packet: Any - prepared_generation_id: Any - storage_scheme: str +class _ConfiguredExternalCheckerExecution: + """Resolve the fixed Rust provider only when a locked image is invoked.""" + def __init__(self, settings) -> None: + self._settings = settings + self._identity = ExternalServiceAdapterIdentity( + EXTERNAL_CHECKER_CAPABILITY_KEY, + EXTERNAL_CHECKER_PROVIDER_KEY, + ) -class _PublicFactsCheckerProcessor: - """Project the private CHECKER processor result to public facts.""" + @property + def identity(self) -> ExternalServiceAdapterIdentity: + """Expose the sole provider identity without constructing its transport.""" + return self._identity + + def _resolve(self) -> ExternalCheckerExecutionAdapter: + """Create the fixed provider or report absent runtime configuration.""" + if ( + self._settings.external_checker_service_socket is None + and self._settings.external_checker_material_root is None + ): + raise ExternalServiceUnavailableError(self._identity) + return configured_external_checker_execution_factory(self._settings).create( + EXTERNAL_CHECKER_PROVIDER_KEY + ) - def __init__(self, processor: EffectivePreSubmissionProcessor) -> None: - self._processor = processor + async def execute( + self, + request: ExternalCheckerExecutionRequest, + grant: ExternalCheckerMaterialGrant, + ) -> ExternalCheckerExecutionResponse: + """Resolve and invoke the sole provider for one selected binding.""" + return await self._resolve().execute(request, grant) - def abort(self) -> None: - self._processor.abort() + async def health(self) -> ExternalCheckerServiceHealth: + """Resolve the sole provider before checking runtime readiness.""" + return await self._resolve().health() - async def process(self, reader: BinaryIO, workspace: Path) -> PreSubmissionExecutionFacts: - try: - result = await asyncio.to_thread(self._processor.process_blocking, reader, workspace) - return result.bounded_facts() - except DefaultPreSubmissionExecutionError as exc: - raise PreSubmissionInfrastructureUnavailableError(str(exc)) from exc +def configured_external_checker_execution(settings) -> ExternalCheckerExecutionAdapter: + """Create the lazy fixed-provider adapter for selected execution.""" + return _ConfiguredExternalCheckerExecution(settings) -class PreSubmitCheckerExecutionAdapter: - """Build the private CHECKER processor behind dependency-safe facts.""" - def __init__(self, *, catalogue: PreSubmissionCheckerCatalogue, archive_inspector: Any): - self._catalogue = catalogue - self._archive_inspector = archive_inspector +def selected_pre_submit_checker_execution(*, settings, archive_inspector): + """Compose default/PRE checking with deferred project-image transport.""" + return SelectedPreSubmitCheckerExecutionAdapter( + archive_inspector=archive_inspector, + external_execution=configured_external_checker_execution(settings), + ) - @property - def catalogue_manifest_sha256(self) -> str: - return self._catalogue.manifest_sha256 - - def build(self, request: _ExecutionRequest) -> _PublicFactsCheckerProcessor: - return _PublicFactsCheckerProcessor( - EffectivePreSubmissionProcessor( - archive_inspector=self._archive_inspector, - catalogue=self._catalogue, - execution_input=DefaultPreSubmissionExecutionInput( - plan=request.plan, - commitment=request.commitment, - inspection=request.inspection, - manifest=request.manifest, - change_gate=request.change_gate, - packet=request.packet, - prepared_generation_id=request.prepared_generation_id, - storage_scheme=request.storage_scheme, + +def external_checker_registry_reader(session): + """Resolve immutable metadata for trusted approval and execution owners.""" + from app.modules.checkers.external_registry import ExternalCheckerRegistryReader + + return ExternalCheckerRegistryReader(session) + + +async def external_checker_capability_projection( + session, +) -> ExternalCheckerCapabilityProjection: + """Snapshot one default plus every immutable external registry identity.""" + entries = await external_checker_registry_reader(session).list_entries() + definitions = tuple( + sorted( + ( + ExternalCheckerCapabilityDefinition( + capability_id=WORKSTREAM_DEFAULT_CHECKER_ID, + capability_version=WORKSTREAM_DEFAULT_CHECKER_VERSION, + phase="pre_submit", + selectable=False, + registry_entry=None, ), - ) + *( + ExternalCheckerCapabilityDefinition( + capability_id=entry.capability_id, + capability_version=entry.capability_version, + phase=entry.phase, + selectable=True, + registry_entry=entry, + ) + for entry in entries + ), + ), + key=lambda item: ( + item.phase, + item.capability_id, + item.capability_version, + ), ) + ) + body = { + "schema_version": "external_checker_capabilities.v1", + "definitions": [item.model_dump(mode="json") for item in definitions], + } + return ExternalCheckerCapabilityProjection( + definitions=definitions, + manifest_sha256=external_checker_json_hash(body), + ) -def project_guide_pre_submission_catalogue() -> PreSubmissionCapabilityProjection: - """Compose the canonical immutable pre-submit capabilities for guide setup.""" - return project_guide_pre_submission_capabilities(build_pre_submission_checker_catalogue()) +def selected_pre_submit_coordinator(session): + """Compose selected PRE custody through CHECKERS' public adapter root.""" + from app.adapters.tasks import selected_pre_submit_task_guard + from app.modules.checkers.selected_pre_submit_coordination import ( + SelectedPreSubmitCoordinator, + ) + return SelectedPreSubmitCoordinator( + session, + tasks=selected_pre_submit_task_guard(session), + ) -def project_guide_approval_compiler(*, disabled_checker_ids: str = "") -> tuple[ - PreSubmissionPolicyCompilationPort, PreSubmissionCapabilityProjection, PostSubmitCatalogue, -]: - """Compose one canonical planner and its matching pre/post capabilities.""" - from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue - planner = build_pre_submission_checker_catalogue( - disabled_entry_ids=parse_disabled_pre_submission_checker_ids(disabled_checker_ids), - ) - return planner, project_guide_pre_submission_capabilities(planner), current_post_submit_catalogue() +class _ExecutionRequest(Protocol): + project_id: Any + task_id: Any + assignment_id: Any + commitment: Any + inspection: Any + manifest: Any + change_gate: Any + packet: Any + prepared_generation_id: Any + storage_scheme: str -def installed_post_submit_catalogue() -> PostSubmitCatalogue: - """Resolve catalogue facts only after checking the installed handler registry.""" - from app.modules.checkers.post_submit_catalogue import build_post_submit_catalogue - from app.modules.checkers.runner import default_checker_registry +class SelectedPreSubmitCheckerExecutionAdapter: + """Build the selected default-plus-external PRE processor from exact ART facts.""" - return build_post_submit_catalogue(default_checker_registry()) + def __init__(self, *, archive_inspector, external_execution) -> None: + self._archive_inspector = archive_inspector + self._external_execution = external_execution + + def build(self, request: _ExecutionRequest) -> SelectedPreSubmitProcessor: + """Reject any incomplete pre-admission identity before projecting bytes.""" + policy = getattr(request, "external_policy", None) + default_input = getattr(request, "default_checker_input", None) + attempt_id = getattr(request, "attempt_id", None) + attempt_request_digest = getattr(request, "attempt_request_digest", None) + grants = getattr(request, "external_grants", None) + if ( + policy is None + or default_input is None + or attempt_id is None + or attempt_request_digest is None + or grants is None + ): + raise SelectedPreSubmitInfrastructureUnavailable( + "material_unavailable" + ) + identity = PreSubmitExternalCheckerIdentity( + project_id=request.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + prepared_generation_id=request.prepared_generation_id, + attempt_id=attempt_id, + attempt_request_digest=attempt_request_digest, + effective_plan_sha256=policy.policy_sha256, + ) + material = PreSubmitExternalCheckerMaterial( + role="submission_archive", + prepared_generation_id=request.prepared_generation_id, + attempt_id=attempt_id, + attempt_request_digest=attempt_request_digest, + archive_sha256=request.commitment.sha256, + archive_byte_count=request.commitment.byte_count, + semantic_manifest_sha256=request.manifest.sha256, + media_type="application/zip", + ) + return SelectedPreSubmitProcessor( + archive_inspector=self._archive_inspector, + inspection=request.inspection, + policy=policy, + default_input=default_input, + identity=identity, + material=material, + grants=grants, + execution=self._external_execution, + ) def checker_history_repository(session) -> CheckerHistoryReadPort: """Compose retained CHECKERS reads through TASK's public ownership port.""" from app.modules.checkers.history import CheckerHistoryRepository + return CheckerHistoryRepository(session) @@ -194,108 +297,85 @@ def post_submission_executor(*, sessions, materialization): from app.adapters.outbox import outbox_append from app.modules.checkers.execution import PostSubmissionExecutor from app.adapters.auth import post_submit_execution_authority - from app.modules.checkers.runner import default_checker_registry - return PostSubmissionExecutor(sessions=sessions, materialization=materialization, + + return PostSubmissionExecutor( + sessions=sessions, + materialization=materialization, execute_authority=post_submit_execution_authority, finalize_authority=post_submit_execution_authority, - registry=default_checker_registry(), outbox=outbox_append) + outbox=outbox_append, + ) def checker_output_reservations(session): - """Resolve exact active run/lease custody; current catalogue has zero slots.""" + """Resolve exact active run/lease custody for artifact output reservations.""" from app.modules.checkers.execution_coordination import CheckerOutputReservations + return CheckerOutputReservations(session) def current_post_submit_execution(session): """Keep ART currentness checks behind the CHECKERS owner port.""" from app.modules.checkers.execution_coordination import CurrentExecution + return CurrentExecution(session) -def submission_evaluation_content( +def selected_submission_evaluation_content( task_context: TaskSubmissionContextFacts, - project_context: ProjectLockedPolicyContextFacts, - packet: SubmissionPacketView, - files: tuple[SubmissionBundleFile, ...], - archive_sha256: str, -) -> PostSubmissionEvaluationContent: - """Project inspected files and locked policies without executing a checker. - - Before admission, observed context is the locked TASK context. A dispatcher - must first verify stored Submission lineage against these same facts; this - projection never proves stored ownership or replaces the evaluation guard. - """ - from app.adapters.tasks import validate_submission_policy_context - - validate_submission_policy_context(task_context, project_context) - stamps = task_context.locked_policy - expected = ExpectedPostSubmitContext( - guide_version=stamps.locked_guide_version, - source_id=stamps.locked_guide_source_snapshot_id, - source_hash=stamps.locked_guide_source_snapshot_hash, - effective_policy_id=stamps.locked_effective_project_submission_artifact_policy_id, - effective_policy_hash=stamps.locked_effective_project_submission_artifact_policy_hash, - pre_policy_id=stamps.locked_pre_submit_checker_policy_id, - pre_policy_hash=stamps.locked_pre_submit_checker_bundle_hash, - post_policy_id=stamps.locked_post_submit_checker_policy_id, - post_policy_version=stamps.locked_post_submit_checker_policy_version, - post_policy_hash=stamps.locked_post_submit_checker_policy_hash, - review_policy_id=stamps.locked_review_policy_id, - review_generation=stamps.locked_review_policy_generation, - review_hash=stamps.locked_review_policy_hash, - revision_policy_id=stamps.locked_revision_policy_id, - revision_generation=stamps.locked_revision_policy_generation, - revision_hash=stamps.locked_revision_policy_hash, - ) - effective = json.loads(project_context.effective_policy.value) - policy_inputs = PostSubmitPolicyInputs( - required_evidence_keys=tuple(item["key"] for item in effective.get("required_evidence", []) - if item.get("required", True)), - required_artifact_paths=tuple(item["path"] for item in effective.get("required_artifacts", []) - if item.get("required", True)), - forbidden_artifact_patterns=tuple(item["pattern"] for item in effective.get("forbidden_artifacts", []) - if item.get("pattern")), - required_attestation_terms=tuple(term for term in effective.get("attestation_terms", []) if term), - ) - by_path = {item.normalized_path: item for item in files} - return PostSubmissionEvaluationContent( - project_id=project_context.project_id, - expected_context=expected, - catalogue=installed_post_submit_catalogue(), - policy=CompiledPostSubmitPolicy.model_validate_json(project_context.compiled_post_submit_policy.value), - structural_input=PostSubmissionStructuralInput( - summary=packet.summary, worker_attestation=packet.contributor_attestation, - package_hash=archive_sha256, criteria=task_context.acceptance_criteria or "", - manifest=tuple(PostSubmitManifestEntry( - artifact=item.normalized_path, hash=item.sha256, size_bytes=item.byte_count, - ) for item in files), - evidence=tuple(PostSubmitEvidenceEntry( - label=key, type="file", key=key, uri=item.normalized_path, hash=item.sha256, - ) for key in policy_inputs.required_evidence_keys - for item in (by_path.get(required_evidence_path(key)),) if item is not None), - policy_inputs=policy_inputs, - observed_context=ObservedPostSubmitContext(**expected.model_dump()), + policy: LockedExternalPostSubmitPolicy, +) -> SelectedPostSubmissionContent: + """Bind selected POST policy to exact TASK lineage without a catalogue hash.""" + locked = task_context.locked_policy + references = task_context.locked_project_context + return SelectedPostSubmissionContent( + project_id=references.project_id, + expected_context=SelectedPostSubmitLockedContext( + guide_version=locked.locked_guide_version, + source_id=locked.locked_guide_source_snapshot_id, + source_hash=locked.locked_guide_source_snapshot_hash, + effective_policy_id=(locked.locked_effective_project_submission_artifact_policy_id), + effective_policy_hash=(locked.locked_effective_project_submission_artifact_policy_hash), + pre_policy_id=locked.locked_pre_submit_checker_policy_id, + pre_policy_hash=locked.locked_pre_submit_checker_bundle_hash, + post_policy_id=(locked.locked_post_submit_checker_policy_id), + post_policy_version=(locked.locked_post_submit_checker_policy_version), + post_policy_hash=(locked.locked_post_submit_checker_policy_hash), + review_policy_id=locked.locked_review_policy_id, + review_generation=locked.locked_review_policy_generation, + review_hash=locked.locked_review_policy_hash, + revision_policy_id=locked.locked_revision_policy_id, + revision_generation=locked.locked_revision_policy_generation, + revision_hash=locked.locked_revision_policy_hash, ), + policy=policy, ) -def delivery_bound_post_submission_executor(*, sessions, materialization, envelope, request): +def delivery_bound_post_submission_executor( + *, sessions, materialization, envelope, request, selected_execution=None +): """Add invocation custody to the existing executor without registering delivery.""" from app.adapters.auth import post_submit_execution_authority from app.adapters.outbox import outbox_append, outbox_invocation_fence from app.adapters.tasks import evaluation_task_guard from app.modules.checkers.delivery_authority import DeliveryExecutionAuthority from app.modules.checkers.execution import PostSubmissionExecutor - from app.modules.checkers.runner import default_checker_registry def authority(session): return DeliveryExecutionAuthority( - authority=post_submit_execution_authority(session), tasks=evaluation_task_guard(session), - fence=outbox_invocation_fence(session), envelope=envelope, request=request, + authority=post_submit_execution_authority(session), + tasks=evaluation_task_guard(session), + fence=outbox_invocation_fence(session), + envelope=envelope, + request=request, ) return PostSubmissionExecutor( - sessions=sessions, execute_authority=authority, finalize_authority=authority, - materialization=materialization, registry=default_checker_registry(), outbox=outbox_append, + sessions=sessions, + execute_authority=authority, + finalize_authority=authority, + materialization=materialization, + selected_execution=selected_execution, + outbox=outbox_append, ) diff --git a/backend/app/adapters/checkers/external_service.py b/backend/app/adapters/checkers/external_service.py index ffa28569a..f1f402bc7 100644 --- a/backend/app/adapters/checkers/external_service.py +++ b/backend/app/adapters/checkers/external_service.py @@ -21,6 +21,7 @@ ExternalServiceUnavailableError, ) from app.modules.checkers.api.external import ( + EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS, MAX_RESULT_BYTES, ExternalCheckerContractError, ExternalCheckerExecutionRequest, @@ -31,7 +32,7 @@ EXTERNAL_CHECKER_PROVIDER_KEY = "unix_socket" _PROTOCOL = "external_checker_service.v1" _MAXIMUM_RESPONSE_BYTES = MAX_RESULT_BYTES + 16_384 -_EXECUTION_OVERHEAD_SECONDS = 60.0 +_EXECUTION_OVERHEAD_SECONDS = EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS / 1000 class UnixSocketExternalCheckerAdapter: diff --git a/backend/app/adapters/outbox/__init__.py b/backend/app/adapters/outbox/__init__.py index 36053137f..c39c7eea1 100644 --- a/backend/app/adapters/outbox/__init__.py +++ b/backend/app/adapters/outbox/__init__.py @@ -1,6 +1,6 @@ """Explicit composition of the shared delivery owner; feature registration is separate.""" -from collections.abc import Callable +from collections.abc import Callable, Mapping from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.modules.authorization.api.outbox_dispatch import OutboxDispatchAuthorizationPort @@ -21,6 +21,7 @@ def outbox_delivery( authorization_factory: Callable[[AsyncSession], OutboxDispatchAuthorizationPort], registry: HandlerRegistry, options: DeliveryOptions, + event_options: Mapping[tuple[str, int], DeliveryOptions] | None = None, ) -> OutboxDelivery: """No default authority, installed handler, worker or runtime discovery.""" return OutboxDelivery( @@ -28,23 +29,67 @@ def outbox_delivery( authorization_factory=authorization_factory, registry=registry, options=options, + event_options=event_options, ) def production_outbox_delivery(session_factory): - """Install the exact assignment handler with separate dispatcher and feature authority.""" - from app.adapters.auth import outbox_dispatch_authorization, assignment_invalidation_authorization - from app.adapters.tasks import TransactionalAssignmentInvalidationHandler + """Install exact assignment and selected evaluation handlers.""" + from app.adapters.artifacts import worker_post_submission_materialization + from app.adapters.auth import ( + outbox_dispatch_authorization, + assignment_invalidation_authorization, + ) + from app.adapters.tasks import ( + TransactionalAssignmentInvalidationHandler, + evaluation_completion_handler, + evaluation_request_handler, + ) + from app.modules.checkers.api.execution import COMPLETION_EVENT, REQUEST_EVENT + from app.modules.checkers.api.external_policy import ( + SELECTED_POST_DELIVERY_LEASE_SECONDS, + SELECTED_POST_HANDLER_TIMEOUT_SECONDS, + ) from app.modules.tasks.api.assignment_invalidation import ASSIGNMENT_INVALIDATION_EVENT return outbox_delivery( - session_factory, authorization_factory=outbox_dispatch_authorization, - registry=HandlerRegistry([(ASSIGNMENT_INVALIDATION_EVENT, 1, - TransactionalAssignmentInvalidationHandler( - session_factory, observer=CommittedInvocationReader(session_factory), - authorization_factory=assignment_invalidation_authorization, - ), - )]), options=DeliveryOptions(), + session_factory, + authorization_factory=outbox_dispatch_authorization, + registry=HandlerRegistry( + [ + ( + ASSIGNMENT_INVALIDATION_EVENT, + 1, + TransactionalAssignmentInvalidationHandler( + session_factory, + observer=CommittedInvocationReader(session_factory), + authorization_factory=assignment_invalidation_authorization, + ), + ), + ( + REQUEST_EVENT, + 1, + evaluation_request_handler( + sessions=session_factory, + materialization=worker_post_submission_materialization( + sessions=session_factory + ), + ), + ), + ( + COMPLETION_EVENT, + 1, + evaluation_completion_handler(sessions=session_factory), + ), + ] + ), + options=DeliveryOptions(), + event_options={ + (REQUEST_EVENT, 1): DeliveryOptions( + lease_seconds=SELECTED_POST_DELIVERY_LEASE_SECONDS, + handler_timeout_seconds=SELECTED_POST_HANDLER_TIMEOUT_SECONDS, + ) + }, ) diff --git a/backend/app/adapters/projects/__init__.py b/backend/app/adapters/projects/__init__.py index d0b817337..39e34758b 100644 --- a/backend/app/adapters/projects/__init__.py +++ b/backend/app/adapters/projects/__init__.py @@ -1,27 +1,32 @@ """PROJECT-owned composition adapters.""" from app.modules.projects.api.guide_activation import ( - GuideActivationAuthorizationPort, GuideActivationPort, GuideContributionPolicyPort, + GuideActivationAuthorizationPort, + GuideActivationPort, + GuideContributionPolicyPort, ) from app.modules.projects.api.guide_documents import ProjectGuideDocumentScopePort -from app.modules.projects.api.guide_proposals import GuideProposalOperationsPort, GuideCorrectionDispatchPort +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.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy from app.modules.projects.post_policy.delivery import PostPolicyServiceAuthority from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.modules.projects.api.guide_documents import GuideDocumentManifestPort, GuideDocumentAccessFactory +from app.modules.projects.api.guide_documents import ( + GuideDocumentManifestPort, + GuideDocumentAccessFactory, +) from app.interfaces.project_agents import ProjectGuideAgentRuntime from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.modules.checkers.api.pre_submit_catalogue import PreSubmissionCapabilityProjection -from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue -from app.modules.checkers.api.policy_compilation import PreSubmissionPolicyCompilationPort +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.authorization.api import ( ActorIdentityFacts, GuideSufficiencyProjectionAuthorizationPort, @@ -64,8 +69,7 @@ def project_guide_compilation_delivery_port( *, material_factory: Callable[[AsyncSession], GuideDocumentManifestPort], document_access_factory: GuideDocumentAccessFactory, - pre_capabilities: PreSubmissionCapabilityProjection, - post_capabilities: PostSubmitCatalogue, + checker_capabilities: ExternalCheckerCapabilityProjection, request_authority: RequestAuthority, execution_authority: GuideCompilationAuthorizationContext, configuration_factory: Callable[[], ProjectGuideRuntimeConfiguration], @@ -84,15 +88,13 @@ def project_guide_compilation_delivery_port( return LiveGuideCompilationCoordinator( session_factory, material_factory=material_factory, - pre_capabilities=pre_capabilities, - post_capabilities=post_capabilities, + checker_capabilities=checker_capabilities, request_authority=request_authority, configuration_factory=configuration_factory, execution=project_guide_compilation_execution_port( session_factory, material_factory=material_factory, - pre_submission_capabilities=pre_capabilities, - post_submission_capabilities=post_capabilities, + checker_capabilities=checker_capabilities, authorization_context=execution_authority, runtime_factory=runtime_factory, document_access_factory=document_access_factory, @@ -110,7 +112,8 @@ def project_guide_compilation_delivery_port( async def cleanup_project_guide_runtime_resources(session_factory, *, runtime_factory): """Compose exact retained cleanup scopes without source grants or inference admission.""" from app.modules.projects.guide_compilation.runtime_resources import ( - SqlAlchemyGuideRuntimeCleanupCustody, pending_runtime_resource_cleanup, + SqlAlchemyGuideRuntimeCleanupCustody, + pending_runtime_resource_cleanup, ) async with session_factory() as session: @@ -123,7 +126,10 @@ async def cleanup_project_guide_runtime_resources(session_factory, *, runtime_fa if runtime.identity != configuration.adapter_identity: raise ValueError("cleanup runtime identity mismatch") custody = SqlAlchemyGuideRuntimeCleanupCustody( - session_factory, attempt_id, manifest_sha256, configuration.runtime_key, + session_factory, + attempt_id, + manifest_sha256, + configuration.runtime_key, ) completed += bool(await runtime.cleanup_resources(custody)) except Exception: @@ -137,57 +143,75 @@ async def cleanup_project_guide_runtime_resources(session_factory, *, runtime_fa def project_guide_document_scope_port(session: AsyncSession) -> ProjectGuideDocumentScopePort: """Bind PROJECTS current document scope independently from ART storage.""" - from app.modules.projects.guide_compilation.document_scope import SqlAlchemyProjectGuideDocumentScope + from app.modules.projects.guide_compilation.document_scope import ( + SqlAlchemyProjectGuideDocumentScope, + ) + return SqlAlchemyProjectGuideDocumentScope(session) def project_guide_proposal_service( - session: AsyncSession, authorization: GuideProposalAuthorizationPort, + session: AsyncSession, + authorization: GuideProposalAuthorizationPort, ) -> GuideProposalOperationsPort[ - ActorIdentityFacts, GuideDocumentManifestPort, PreSubmissionCapabilityProjection, - PostSubmitCatalogue, PreSubmissionPolicyCompilationPort, + ActorIdentityFacts, + GuideDocumentManifestPort, + ExternalCheckerCapabilityProjection, ]: """Compose the sole proposal owner with explicit authority.""" + from app.adapters.checkers import external_checker_registry_reader from app.modules.projects.guide_compilation.proposal_service import GuideProposalService - return GuideProposalService(session, authorization) + return GuideProposalService(session, authorization, external_checker_registry_reader(session)) def project_guide_correction_dispatch( - session: AsyncSession, authorization: ProjectGuideCompilationAuthorizationPort, *, - material: GuideDocumentManifestPort, pre: PreSubmissionCapabilityProjection, - post: PostSubmitCatalogue, configuration: ProjectGuideRuntimeConfiguration, + session: AsyncSession, + authorization: ProjectGuideCompilationAuthorizationPort, + *, + material: GuideDocumentManifestPort, + capabilities: ExternalCheckerCapabilityProjection, + configuration: ProjectGuideRuntimeConfiguration, ) -> GuideCorrectionDispatchPort[ActorIdentityFacts]: """Compose human correction admission and existing queue dispatch without inference.""" - from app.modules.projects.guide_compilation.correction_dispatch import GuideCorrectionDispatchService + from app.modules.projects.guide_compilation.correction_dispatch import ( + GuideCorrectionDispatchService, + ) from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs return GuideCorrectionDispatchService( - session, authorization, CompilationRequestInputs(material, pre, post, configuration), + session, + authorization, + CompilationRequestInputs(material, capabilities, configuration), ) def project_post_policy_service( - session: AsyncSession, authorization: PostPolicyAuthorizationPort, catalogue: PostSubmitCatalogue, -) -> PostPolicyOperationsPort[ActorIdentityFacts, GuideProposalAuthorizationPort, CompiledPostSubmitPolicy]: + session: AsyncSession, + authorization: PostPolicyAuthorizationPort, +) -> PostPolicyOperationsPort[ActorIdentityFacts, GuideProposalAuthorizationPort, object]: """Compose the canonical post-policy owner with explicitly supplied authority.""" from app.modules.projects.post_policy.service import PostPolicyService - return PostPolicyService(session, authorization, catalogue) + return PostPolicyService(session, authorization) def project_post_policy_delivery_port( - session_factory: async_sessionmaker[AsyncSession], *, - authority: PostPolicyServiceAuthority, catalogue: PostSubmitCatalogue, + session_factory: async_sessionmaker[AsyncSession], + *, + authority: PostPolicyServiceAuthority, ) -> PostPolicyDeliveryPort: """Compose deterministic delivery without document or agent capabilities.""" from app.modules.projects.post_policy.delivery import PostPolicyDeliveryService - return PostPolicyDeliveryService(session_factory, authority=authority, catalogue=catalogue) + return PostPolicyDeliveryService(session_factory, authority=authority) async def pending_post_policy_approvals( - session_factory: async_sessionmaker[AsyncSession], *, after: UUID | None, limit: int, + session_factory: async_sessionmaker[AsyncSession], + *, + after: UUID | None, + limit: int, ) -> list[UUID]: """Read only pending immutable approval identifiers for a bounded internal scan.""" from app.modules.projects.post_policy.delivery import pending_approval_ids @@ -204,14 +228,18 @@ async def dispatch_post_policy_derivation_after_commit(approval_operation_id: UU def project_guide_activation_port( - session: AsyncSession, *, contribution: GuideContributionPolicyPort, planner: PreSubmissionPolicyCompilationPort, - pre_catalogue: PreSubmissionCapabilityProjection, post_catalogue: PostSubmitCatalogue, + session: AsyncSession, + *, + contribution: GuideContributionPolicyPort, 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.modules.projects.guide_activation.service import GuideActivationService return GuideActivationService( - session, contribution=contribution, planner=planner, pre_catalogue=pre_catalogue, - post_catalogue=post_catalogue, authorization=authorization, + session, + contribution=contribution, + authorization=authorization, + registry=external_checker_registry_reader(session), ) diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index 68edb6232..482bb4924 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -15,6 +15,7 @@ SubmissionCreationRequest, SubmissionCreationResult, TaskSubmissionContextPort, + TaskSubmissionContextRequest, ) from app.modules.tasks.repository import TaskRepository from app.modules.tasks.api import TaskSubmissionContextFacts @@ -23,8 +24,10 @@ from app.modules.tasks.authorized_commands import AuthorizedTaskCommands from app.modules.tasks.api import TaskAuthorizationPort, TaskTransitionAuditPort from app.modules.tasks.submission_participants import ( - SubmissionArtifactAdmissionRequest, SubmissionArtifactAdmissionResult, - SubmissionArtifactReplayRequest, SubmissionArtifactBindingFacts, + SubmissionArtifactAdmissionRequest, + SubmissionArtifactAdmissionResult, + SubmissionArtifactReplayRequest, + SubmissionArtifactBindingFacts, ) from app.modules.artifacts.api.submission_admission import ConsumedSubmissionAdmissionRequest from app.modules.tasks.submission_composition import TaskSubmissionCreationService @@ -32,14 +35,65 @@ from app.modules.tasks.api.assignment_invalidation import AssignmentInvalidationUnavailable from app.modules.outbox.api import HandlerOutcome from app.modules.checkers.api.execution import EvaluationTaskGuard +from app.modules.checkers.api.selected_pre_submit import SelectedPreSubmitReservationFacts def evaluation_task_guard(session: AsyncSession) -> EvaluationTaskGuard: """Expose TASK-owned locking through the required CHECKERS consumer port.""" from app.modules.tasks.post_submit_routing.evaluation_guard import TaskEvaluationGuard + return TaskEvaluationGuard(session) +class _SelectedPreSubmitTaskGuard: + """Project selected PRE custody through TASK's canonical submission lock.""" + + def __init__(self, session: AsyncSession) -> None: + self._contexts = task_submission_context_port(session) + + async def lock_selected_pre_submit_scope( + self, reservation: SelectedPreSubmitReservationFacts + ) -> None: + lineage = reservation.admission_lineage + if lineage is None: + raise RuntimeError("selected PRE admission lineage is unavailable") + context = await self._contexts.lock_submission_context( + TaskSubmissionContextRequest( + task_id=reservation.task_id, + assignment_id=reservation.assignment_id, + contributor_id=reservation.actor_profile_id, + predecessor_submission_id=lineage.predecessor_submission_id, + ) + ) + predecessor = context.predecessor + references = context.locked_project_context + if ( + context.task_id != reservation.task_id + or context.assignment_id != reservation.assignment_id + or context.contributor_id != reservation.actor_profile_id + or (predecessor.submission_id if predecessor is not None else None) + != lineage.predecessor_submission_id + or (predecessor.version if predecessor is not None else None) + != lineage.predecessor_submission_version + or context.checker_remediation_manifest_id + != lineage.checker_remediation_manifest_id + or references.project_id != reservation.project_id + or references.guide_version != lineage.guide_version + or references.source_snapshot_id != lineage.source_snapshot_id + or references.source_snapshot_hash != lineage.source_snapshot_sha256 + or references.effective_policy_id != lineage.effective_policy_id + or references.effective_policy_hash != lineage.effective_policy_sha256 + or references.pre_submit_policy_id != lineage.pre_submit_policy_id + or references.pre_submit_policy_bundle_hash != lineage.pre_submit_policy_sha256 + ): + raise RuntimeError("selected PRE TASK custody changed") + + +def selected_pre_submit_task_guard(session: AsyncSession) -> _SelectedPreSubmitTaskGuard: + """Expose TASK-first selected PRE locking to CHECKERS composition.""" + return _SelectedPreSubmitTaskGuard(session) + + class TransactionalAssignmentInvalidationHandler: """Exact registered handler; commits before acknowledgement, with explicit authority.""" @@ -48,13 +102,18 @@ def __init__(self, session_factory, *, observer, authorization_factory): self._observer, self._authorization = observer, authorization_factory async def __call__(self, envelope): - from app.adapters.audit import assignment_invalidation_audit, committed_authority_invalidation + from app.adapters.audit import ( + assignment_invalidation_audit, + committed_authority_invalidation, + ) from app.adapters.outbox import outbox_invocation_fence try: async with self._sessions() as session, session.begin(): outcome = await AssignmentInvalidationOperation( - session, observer=self._observer, fence=outbox_invocation_fence(session), + session, + observer=self._observer, + fence=outbox_invocation_fence(session), causes=committed_authority_invalidation(self._sessions), authorization=self._authorization(session), audit=assignment_invalidation_audit(session), @@ -66,6 +125,7 @@ async def __call__(self, envelope): __all__ = ( + "selected_pre_submit_task_guard", "TransactionalAssignmentInvalidationHandler", "task_commands", "task_service", @@ -75,28 +135,32 @@ async def __call__(self, envelope): def task_service(session: AsyncSession, *, settings: Settings) -> TaskService: - """Compose exact PROJECTS custody and installed CHECKERS at the existing TASK root.""" + """Compose exact PROJECTS custody at the existing TASK transaction root.""" from app.adapters.projects import project_locked_policy_context_port - from app.adapters.checkers import project_guide_approval_compiler, installed_post_submit_catalogue - planner, _, _ = project_guide_approval_compiler( - disabled_checker_ids=settings.artifact_pre_submission_checker_disabled_ids, - ) return TaskService( - session, project_contexts=project_locked_policy_context_port(session), - pre_submit_planner=planner, post_submit_catalogue=installed_post_submit_catalogue, + session, + project_contexts=project_locked_policy_context_port(session), ) def task_commands( - session: AsyncSession, *, authorization: TaskAuthorizationPort, - audit: TaskTransitionAuditPort, actor_profile_id: UUID, settings: Settings, + session: AsyncSession, + *, + authorization: TaskAuthorizationPort, + audit: TaskTransitionAuditPort, + actor_profile_id: UUID, + settings: Settings, ) -> AuthorizedTaskCommands: """Compose TASK commands without exposing private product imports to delivery.""" from app.adapters.artifacts import task_guide_documents_port from app.adapters.contributions import locked_compensation_terms_port + return AuthorizedTaskCommands( - session, authorization=authorization, audit=audit, actor_profile_id=actor_profile_id, + session, + authorization=authorization, + audit=audit, + actor_profile_id=actor_profile_id, contexts=task_service(session, settings=settings), guide_documents=task_guide_documents_port(session, settings), compensation_terms=locked_compensation_terms_port(session), @@ -106,6 +170,7 @@ def task_commands( def contributor_task_repository(session: AsyncSession) -> TaskRepository: """Compose contributor reads with CON's bounded locked-terms public port.""" from app.adapters.contributions import locked_compensation_terms_port + return TaskRepository(session, compensation_terms=locked_compensation_terms_port(session)) @@ -132,35 +197,76 @@ async def consume( packet_sha256=request.packet.sha256, ) ) - if (result.binding_id is None or result.status != "consumed" - or result.material is None or result.binding_decision_id is None): + if ( + result.binding_id is None + or result.status != "consumed" + or result.material is None + or result.binding_decision_id is None + ): raise RuntimeError("admission did not produce a binding") - from app.adapters.checkers import submission_evaluation_content + from app.adapters.checkers import selected_submission_evaluation_content + from app.modules.checkers.api.external_policy import ( + validate_stored_external_post_policy, + ) + + selected_post_policy = ( + validate_stored_external_post_policy(result.selected_post_submit_policy_body) + if result.selected_post_submit_policy_body is not None + else None + ) + if selected_post_policy is None: + raise RuntimeError("external checker admission policy is unavailable") return SubmissionArtifactAdmissionResult( - binding_id=result.binding_id, content_id=result.content_id, + binding_id=result.binding_id, + content_id=result.content_id, binding_decision_id=result.binding_decision_id, archive_sha256=result.material.archive_sha256, byte_count=result.material.archive_byte_count, - evaluation_content=submission_evaluation_content( - request.task_context, request.project_context, request.packet, - result.material.files, result.material.archive_sha256, + selected_post_submit_policy=selected_post_policy, + selected_evaluation_content=selected_submission_evaluation_content( + request.task_context, selected_post_policy ), ) - async def read_consumed(self, request: SubmissionArtifactReplayRequest) -> SubmissionArtifactBindingFacts: - result = await self._admissions.read_consumed(ConsumedSubmissionAdmissionRequest( - admission_id=request.admission_id, project_id=request.project_id, - task_id=request.task_id, assignment_id=request.assignment_id, - contributor_id=request.contributor_id, submission_id=request.submission_id, - submission_version=request.submission_version, packet_sha256=request.packet_sha256, - )) - if (result.binding_id is None or result.material is None - or result.binding_decision_id is None or result.status != "consumed"): + async def read_consumed( + self, request: SubmissionArtifactReplayRequest + ) -> SubmissionArtifactBindingFacts: + result = await self._admissions.read_consumed( + ConsumedSubmissionAdmissionRequest( + admission_id=request.admission_id, + project_id=request.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + contributor_id=request.contributor_id, + submission_id=request.submission_id, + submission_version=request.submission_version, + packet_sha256=request.packet_sha256, + ) + ) + if ( + result.binding_id is None + or result.material is None + or result.binding_decision_id is None + or result.status != "consumed" + ): raise RuntimeError("retained admission custody unavailable") + from app.adapters.checkers import selected_submission_evaluation_content + from app.modules.checkers.api.external_policy import ( + validate_stored_external_post_policy, + ) + + selected_post_policy = validate_stored_external_post_policy( + result.selected_post_submit_policy_body + ) return SubmissionArtifactBindingFacts( - binding_id=result.binding_id, content_id=result.content_id, + binding_id=result.binding_id, + content_id=result.content_id, binding_decision_id=result.binding_decision_id, - archive_sha256=result.material.archive_sha256, byte_count=result.material.archive_byte_count, + archive_sha256=result.material.archive_sha256, + byte_count=result.material.archive_byte_count, + selected_evaluation_content=selected_submission_evaluation_content( + request.task_context, selected_post_policy + ), ) @@ -206,12 +312,14 @@ def assignment_invalidation_targets(session: AsyncSession): def submission_history_repository(session) -> SubmissionHistoryReadPort: """Compose TASK-owned immutable history selectors and projections.""" from app.modules.tasks.submission_history import SubmissionHistoryRepository + return SubmissionHistoryRepository(session) def submitted_bundle_port(session: AsyncSession) -> SubmittedBundlePort: """Compose the exact immutable Submission read without private owner imports in ART.""" from app.modules.tasks.submitted_bundle import SubmittedBundleReader + return SubmittedBundleReader(session) @@ -222,30 +330,45 @@ def routing_source_preparer(session): from app.modules.tasks.post_submit_routing.source import TaskRoutingSourcePreparer return TaskRoutingSourcePreparer( - session, evaluations=evaluation_coordinator(session), + session, + evaluations=evaluation_coordinator(session), projects=project_locked_policy_context_port(session), ) -def validate_submission_policy_context(task_context: TaskSubmissionContextFacts, project_context: ProjectLockedPolicyContextFacts) -> None: +def validate_submission_policy_context( + task_context: TaskSubmissionContextFacts, project_context: ProjectLockedPolicyContextFacts +) -> None: """Use TASK's canonical stamp projection without exposing its implementation.""" TaskService.validate_submission_policy_context(task_context, project_context) def evaluation_request_handler(*, sessions, materialization): """Compose one hidden TASK request handler; production registration is separate.""" - from app.adapters.checkers import evaluation_coordinator, delivery_bound_post_submission_executor + from app.adapters.checkers import ( + configured_external_checker_execution, + delivery_bound_post_submission_executor, + evaluation_coordinator, + ) from app.adapters.outbox import committed_invocation_reader + from app.core.config import get_settings from app.modules.tasks.evaluation_delivery import EvaluationRequestHandler def executor(envelope, request): + selected_execution = configured_external_checker_execution(get_settings()) return delivery_bound_post_submission_executor( - sessions=sessions, materialization=materialization, envelope=envelope, request=request, + sessions=sessions, + materialization=materialization, + envelope=envelope, + request=request, + selected_execution=selected_execution, ) return EvaluationRequestHandler( - sessions, observer=committed_invocation_reader(sessions), - evaluations=evaluation_coordinator, executor=executor, + sessions, + observer=committed_invocation_reader(sessions), + evaluations=evaluation_coordinator, + executor=executor, ) @@ -289,7 +412,8 @@ def evaluation_completion_handler(*, sessions): from app.modules.tasks.evaluation_completion_delivery import EvaluationCompletionHandler return EvaluationCompletionHandler( - sessions, observer=committed_invocation_reader(sessions), + sessions, + observer=committed_invocation_reader(sessions), outcomes=lambda session: task_post_submit_outcome(session, sessions), ) diff --git a/backend/app/api/deps/guide_activation.py b/backend/app/api/deps/guide_activation.py index dccc8aa08..4ae0db2a6 100644 --- a/backend/app/api/deps/guide_activation.py +++ b/backend/app/api/deps/guide_activation.py @@ -9,12 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.adapters.auth import guide_activation_authorization -from app.adapters.checkers import project_guide_approval_compiler from app.adapters.contributions import contribution_policy_validation_port from app.adapters.projects import project_guide_activation_port from app.adapters.projects.contribution_validation import GuideContributionPolicyValidation from app.api.deps.authorization import ( - _authorization_context, get_authorization_actor, get_authorization_actor_identity, + _authorization_context, + get_authorization_actor, + get_authorization_actor_identity, ) from app.core.api_controls import request_ids from app.db.session import get_db_session @@ -42,18 +43,19 @@ async def get_activation_request( context = _authorization_context(resolved, request_id, correlation_id) actor = await get_authorization_actor_identity(resolved) await session.rollback() - planner, pre, post = project_guide_approval_compiler() try: async with session.begin(): yield ActivationRequest( session, project_guide_activation_port( session, - contribution=GuideContributionPolicyValidation(contribution_policy_validation_port(session)), - planner=planner, pre_catalogue=pre, post_catalogue=post, + contribution=GuideContributionPolicyValidation( + contribution_policy_validation_port(session) + ), authorization=guide_activation_authorization(session, context), ), - actor, request_id, + actor, + request_id, ) finally: if session.in_transaction(): diff --git a/backend/app/api/deps/guide_proposal_http.py b/backend/app/api/deps/guide_proposal_http.py index 0b5eaa91d..95b1ba5d0 100644 --- a/backend/app/api/deps/guide_proposal_http.py +++ b/backend/app/api/deps/guide_proposal_http.py @@ -28,8 +28,17 @@ def proposal_http_error(exc: GuideProposalError) -> StructuredHTTPException: code, status, message = exc.code, 503, "Guide proposal storage unavailable" else: code, status, message = exc.code, 409, "Guide proposal conflicts with current state" + details = None + if exc.code == "approval_blocked" and exc.missing_capabilities: + message = "Guide requires unavailable checker capabilities" + details = { + "missing_capabilities": [ + item.model_dump(mode="json") for item in exc.missing_capabilities + ] + } return StructuredHTTPException( status_code=status, detail=code, error_code=code, error_message=message, + error_details=details, retryable=status == 503, ) diff --git a/backend/app/api/deps/guide_proposals.py b/backend/app/api/deps/guide_proposals.py index 59ba29149..d07fc718b 100644 --- a/backend/app/api/deps/guide_proposals.py +++ b/backend/app/api/deps/guide_proposals.py @@ -2,7 +2,10 @@ from dataclasses import dataclass from collections.abc import AsyncIterator -from app.modules.projects.api.guide_proposals import GuideProposalOperationsPort, GuideCorrectionDispatchPort +from app.modules.projects.api.guide_proposals import ( + GuideProposalOperationsPort, + GuideCorrectionDispatchPort, +) from typing import Annotated, Any from uuid import UUID @@ -10,15 +13,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps.authorization import ( - _authorization_context, get_authorization_actor, get_authorization_actor_identity, + _authorization_context, + get_authorization_actor, + get_authorization_actor_identity, ) from app.core.api_controls import request_ids from app.db.session import get_db_session from app.modules.authorization.api import ActorIdentityFacts from app.modules.projects.api.guide_documents import GuideDocumentManifestPort -from app.modules.checkers.api.pre_submit_catalogue import PreSubmissionCapabilityProjection -from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue -from app.modules.checkers.api.policy_compilation import PreSubmissionPolicyCompilationPort +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.adapters.auth import guide_proposal_authorization, human_guide_compilation_authorization from app.adapters.projects import project_guide_proposal_service, project_guide_correction_dispatch @@ -29,8 +32,9 @@ class ProposalRequest: session: AsyncSession service: GuideProposalOperationsPort[ - ActorIdentityFacts, GuideDocumentManifestPort, PreSubmissionCapabilityProjection, - PostSubmitCatalogue, PreSubmissionPolicyCompilationPort, + ActorIdentityFacts, + GuideDocumentManifestPort, + ExternalCheckerCapabilityProjection, ] actor: ActorIdentityFacts request_id: UUID @@ -58,7 +62,9 @@ async def get_proposal_request( async with session.begin(): yield ProposalRequest( session, - project_guide_proposal_service(session, guide_proposal_authorization(session, context)), + project_guide_proposal_service( + session, guide_proposal_authorization(session, context) + ), actor, request_id, ) @@ -74,19 +80,25 @@ async def get_correction_dispatch( ) -> AsyncIterator[CorrectionDispatchRequest]: """Compose the existing human compilation request port without a runtime.""" from app.adapters.artifacts import guide_document_manifest_port - from app.adapters.checkers import project_guide_approval_compiler + from app.adapters.checkers import external_checker_capability_projection from app.api.deps.authorization import prepared_authorization_service from app.core.config import get_settings from app.core.project_agents import project_guide_runtime_configuration actor = await get_authorization_actor_identity(resolved) - _, pre, post = project_guide_approval_compiler() async with prepared_authorization_service(request, resolved, session) as prepared: # Capture the immutable AUTH context before rollback expires the ORM # identity rows; dispatch still owns a fresh product transaction. await session.rollback() - yield CorrectionDispatchRequest(project_guide_correction_dispatch( - session, human_guide_compilation_authorization(prepared), - material=guide_document_manifest_port(session), pre=pre, post=post, - configuration=project_guide_runtime_configuration(get_settings()), - ), actor) + async with session.begin(): + capabilities = await external_checker_capability_projection(session) + yield CorrectionDispatchRequest( + project_guide_correction_dispatch( + session, + human_guide_compilation_authorization(prepared), + material=guide_document_manifest_port(session), + capabilities=capabilities, + configuration=project_guide_runtime_configuration(get_settings()), + ), + actor, + ) diff --git a/backend/app/api/deps/post_policy.py b/backend/app/api/deps/post_policy.py index f011d5088..53be366e2 100644 --- a/backend/app/api/deps/post_policy.py +++ b/backend/app/api/deps/post_policy.py @@ -14,15 +14,15 @@ from app.adapters.projects.contribution_validation import GuideContributionPolicyDiscovery from app.modules.projects.api.guide_activation_context import GuideContributionDiscoveryPort from app.api.deps.authorization import ( - _authorization_context, get_authorization_actor, get_authorization_actor_identity, + _authorization_context, + get_authorization_actor, + get_authorization_actor_identity, ) from app.core.api_controls import request_ids from app.db.session import get_db_session from app.modules.authorization.api import ActorIdentityFacts from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort -from app.modules.checkers.api.post_submit_catalogue import ( - CompiledPostSubmitPolicy, current_post_submit_catalogue, -) +from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy from app.modules.projects.api.post_policy import PostPolicyOperationsPort @@ -32,7 +32,9 @@ class PostPolicyRequest: session: AsyncSession service: PostPolicyOperationsPort[ - ActorIdentityFacts, GuideProposalAuthorizationPort, CompiledPostSubmitPolicy, + ActorIdentityFacts, + GuideProposalAuthorizationPort, + LockedExternalPostSubmitPolicy, ] guide_authorization: GuideProposalAuthorizationPort actor: ActorIdentityFacts @@ -55,10 +57,12 @@ async def get_post_policy_request( yield PostPolicyRequest( session, project_post_policy_service( - session, post_policy_authorization(session, context), - current_post_submit_catalogue(), + session, + post_policy_authorization(session, context), ), - guide_proposal_authorization(session, context), actor, request_id, + guide_proposal_authorization(session, context), + actor, + request_id, GuideContributionPolicyDiscovery(published_contribution_policy_port(session)), ) finally: diff --git a/backend/app/api/routes/artifact_submissions.py b/backend/app/api/routes/artifact_submissions.py index 20e58e69a..ae8c35662 100644 --- a/backend/app/api/routes/artifact_submissions.py +++ b/backend/app/api/routes/artifact_submissions.py @@ -1,6 +1,5 @@ """Hidden delivery surface for ART-owned contributor bundle preparation.""" -from dataclasses import asdict from typing import Annotated from uuid import UUID @@ -63,10 +62,9 @@ async def prepare_submission_bundle( ] = None, ) -> SubmissionBundlePreparationResponse | JSONResponse: """Run the hidden continuous ZIP preparation surface; AUTH remains fail closed.""" - if None in (assignment_id, idempotency_key, summary, contributor_attestation): + if None in (assignment_id, idempotency_key): raise HTTPException(status_code=404, detail="Task not found") assert assignment_id is not None and idempotency_key is not None - assert summary is not None and contributor_attestation is not None try: identifiers = ( UUID(task_id), @@ -87,8 +85,8 @@ async def prepare_submission_bundle( assignment_id=identifiers[1], predecessor_submission_id=identifiers[3], idempotency_key=identifiers[2], - summary=summary, - contributor_attestation=contributor_attestation, + summary=summary or "", + contributor_attestation=contributor_attestation or "", media_type=request.headers.get("content-type", ""), byte_source=request.stream(), ) @@ -102,7 +100,7 @@ async def prepare_submission_bundle( details={ "status": "failed", "eligible_to_submit": exc.facts.eligible, - "results": [asdict(entry) for entry in exc.facts.entries], + "results": exc.public_results(), }, ) except SubmissionBundlePreparationInfrastructureUnavailable as exc: diff --git a/backend/app/api/routes/guide_proposals.py b/backend/app/api/routes/guide_proposals.py index 8057e63f8..6c8cdff6d 100644 --- a/backend/app/api/routes/guide_proposals.py +++ b/backend/app/api/routes/guide_proposals.py @@ -7,18 +7,32 @@ from sqlalchemy.exc import SQLAlchemyError from app.adapters.artifacts import guide_document_manifest_port -from app.adapters.checkers import project_guide_approval_compiler +from app.adapters.checkers import external_checker_capability_projection from app.api.deps.authorization import enforce_human_authorization_read -from app.api.deps.guide_proposals import ProposalRequest, CorrectionDispatchRequest, get_proposal_request, get_correction_dispatch +from app.api.deps.guide_proposals import ( + ProposalRequest, + CorrectionDispatchRequest, + get_proposal_request, + get_correction_dispatch, +) from app.core.api_controls import ApiErrorResponse from app.modules.projects.api.guide_proposal_package import GuideProposalReviewPackage from app.modules.projects.api.guide_proposals import ( - GuideProposalApproval, GuideProposalApprovalInput, GuideProposalApprovalReceipt, - GuideProposalCorrection, GuideProposalCorrectionInput, GuideProposalCorrectionReceipt, - GuideProposalError, GuideProposalSelection, GuideProposalDispatchResponse, + GuideProposalApproval, + GuideProposalApprovalInput, + GuideProposalApprovalReceipt, + GuideProposalCorrection, + GuideProposalCorrectionInput, + GuideProposalCorrectionReceipt, + GuideProposalError, + GuideProposalSelection, + GuideProposalDispatchResponse, ) from app.api.deps.guide_proposal_http import ( - IDEMPOTENCY_PARAMETER, require_proposal_key, proposal_http_error, require_matching_target, + IDEMPOTENCY_PARAMETER, + require_proposal_key, + proposal_http_error, + require_matching_target, ) from app.adapters.projects import dispatch_post_policy_derivation_after_commit @@ -31,18 +45,24 @@ @router.get( - "/proposal", response_model=GuideProposalReviewPackage, - openapi_extra={"x-workstream-action-id": 'project.guide_compilation.review_package.read'}, + "/proposal", + response_model=GuideProposalReviewPackage, + openapi_extra={"x-workstream-action-id": "project.guide_compilation.review_package.read"}, ) async def review_proposal( - project_id: UUID, guide_id: UUID, compilation_id: UUID, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, request: Annotated[ProposalRequest, Depends(get_proposal_request)], ) -> GuideProposalReviewPackage: """Read the exact finalized proposal and its approval/correction target.""" try: response = await request.service.review_package( - GuideProposalSelection(project_id=project_id, guide_id=guide_id, compilation_id=compilation_id), - actor=request.actor, request_id=request.request_id, + GuideProposalSelection( + project_id=project_id, guide_id=guide_id, compilation_id=compilation_id + ), + actor=request.actor, + request_id=request.request_id, ) await request.session.commit() return response @@ -53,26 +73,32 @@ async def review_proposal( @router.post( - "/pre-submission-approval", response_model=GuideProposalApprovalReceipt, - openapi_extra={"x-workstream-action-id": 'project.submission_artifact_policy.approve', - "parameters": [IDEMPOTENCY_PARAMETER]}, + "/pre-submission-approval", + response_model=GuideProposalApprovalReceipt, + openapi_extra={ + "x-workstream-action-id": "project.submission_artifact_policy.approve", + "parameters": [IDEMPOTENCY_PARAMETER], + }, dependencies=[Depends(require_proposal_key)], ) async def approve_proposal( - project_id: UUID, guide_id: UUID, compilation_id: UUID, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, payload: GuideProposalApprovalInput, key: Annotated[UUID, Depends(require_proposal_key)], request: Annotated[ProposalRequest, Depends(get_proposal_request)], ) -> GuideProposalApprovalReceipt: """Approve one displayed pre-submission policy without running inference.""" require_matching_target(payload.target, project_id, guide_id, compilation_id) - planner, pre, post = project_guide_approval_compiler() try: + capabilities = await external_checker_capability_projection(request.session) response = await request.service.approve( GuideProposalApproval(**payload.model_dump(), idempotency_key=key), - actor=request.actor, request_id=request.request_id, + actor=request.actor, + request_id=request.request_id, material=guide_document_manifest_port(request.session), - pre_capabilities=pre, post_capabilities=post, planner=planner, + capabilities=capabilities, ) await request.session.commit() await dispatch_post_policy_derivation_after_commit(response.operation_id) @@ -84,13 +110,19 @@ async def approve_proposal( @router.post( - "/corrections", response_model=GuideProposalCorrectionReceipt, status_code=201, - openapi_extra={"x-workstream-action-id": 'project.guide_compilation.correction.request', - "parameters": [IDEMPOTENCY_PARAMETER]}, + "/corrections", + response_model=GuideProposalCorrectionReceipt, + status_code=201, + openapi_extra={ + "x-workstream-action-id": "project.guide_compilation.correction.request", + "parameters": [IDEMPOTENCY_PARAMETER], + }, dependencies=[Depends(require_proposal_key)], ) async def correct_proposal( - project_id: UUID, guide_id: UUID, compilation_id: UUID, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, payload: GuideProposalCorrectionInput, key: Annotated[UUID, Depends(require_proposal_key)], request: Annotated[ProposalRequest, Depends(get_proposal_request)], @@ -100,7 +132,8 @@ async def correct_proposal( try: response = await request.service.request_correction( GuideProposalCorrection(**payload.model_dump(), idempotency_key=key), - actor=request.actor, request_id=request.request_id, + actor=request.actor, + request_id=request.request_id, ) await request.session.commit() return response @@ -111,19 +144,26 @@ async def correct_proposal( @router.post( - "/corrections/{correction_operation_id}/dispatch", response_model=GuideProposalDispatchResponse, + "/corrections/{correction_operation_id}/dispatch", + response_model=GuideProposalDispatchResponse, status_code=202, - openapi_extra={"x-workstream-action-id": 'project.guide_compilation.request'}, + openapi_extra={"x-workstream-action-id": "project.guide_compilation.request"}, ) async def dispatch_correction( - project_id: UUID, guide_id: UUID, compilation_id: UUID, correction_operation_id: UUID, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, + correction_operation_id: UUID, request: Annotated[CorrectionDispatchRequest, Depends(get_correction_dispatch)], ) -> GuideProposalDispatchResponse: """Manually dispatch the selected correction; its identity makes retries idempotent.""" try: return await request.service.dispatch( - GuideProposalSelection(project_id=project_id, guide_id=guide_id, compilation_id=compilation_id), - correction_operation_id, actor=request.actor, + GuideProposalSelection( + project_id=project_id, guide_id=guide_id, compilation_id=compilation_id + ), + correction_operation_id, + actor=request.actor, ) except GuideProposalError as exc: raise proposal_http_error(exc) from exc diff --git a/backend/app/api/routes/post_policy.py b/backend/app/api/routes/post_policy.py index 8b2000213..b4a3635ab 100644 --- a/backend/app/api/routes/post_policy.py +++ b/backend/app/api/routes/post_policy.py @@ -10,28 +10,41 @@ from app.api.deps.authorization import enforce_human_authorization_read from app.api.deps.guide_proposal_http import ( - IDEMPOTENCY_PARAMETER, proposal_http_error, require_matching_target, require_proposal_key, + IDEMPOTENCY_PARAMETER, + proposal_http_error, + require_matching_target, + require_proposal_key, ) from app.api.deps.post_policy import PostPolicyRequest, get_post_policy_request from app.core.api_controls import ApiErrorResponse -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy +from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy from app.modules.projects.api.guide_proposals import GuideProposalError from app.modules.projects.api.post_policy import ( - PostPolicyApproval, PostPolicyApprovalInput, PostPolicyCorrection, PostPolicyCorrectionInput, - PostPolicyReceipt, PostPolicyReviewPackage, PostPolicySelection, PostPolicyTarget, + PostPolicyApproval, + PostPolicyApprovalInput, + PostPolicyCorrection, + PostPolicyCorrectionInput, + PostPolicyReceipt, + PostPolicyReviewPackage, + PostPolicySelection, + PostPolicyTarget, ) router = APIRouter( prefix="/projects/{project_id}/guides/{guide_id}/compilations/{compilation_id}" - "/post-submission-policies/{policy_id}", - tags=["projects"], dependencies=[Depends(enforce_human_authorization_read)], + "/post-submission-policies/{policy_id}", + tags=["projects"], + dependencies=[Depends(enforce_human_authorization_read)], responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, ) def require_policy_target( - target: PostPolicyTarget, project_id: UUID, guide_id: UUID, - compilation_id: UUID, policy_id: UUID, + target: PostPolicyTarget, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, + policy_id: UUID, ) -> None: """Bind every body identity to the selected public resource.""" require_matching_target(target.proposal, project_id, guide_id, compilation_id) @@ -40,21 +53,31 @@ def require_policy_target( @router.get( - "", response_model=PostPolicyReviewPackage[CompiledPostSubmitPolicy], + "", + response_model=PostPolicyReviewPackage[LockedExternalPostSubmitPolicy], openapi_extra={"x-workstream-action-id": "project.guide_compilation.review_package.read"}, ) async def read_post_policy( - project_id: UUID, guide_id: UUID, compilation_id: UUID, policy_id: UUID, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, + policy_id: UUID, request: Annotated[PostPolicyRequest, Depends(get_post_policy_request)], -) -> PostPolicyReviewPackage[CompiledPostSubmitPolicy]: +) -> PostPolicyReviewPackage[LockedExternalPostSubmitPolicy]: """Read the complete canonical body and discover any saved correction.""" try: response = await request.service.review_package( - PostPolicySelection(project_id=project_id, guide_id=guide_id, - compilation_id=compilation_id, policy_id=policy_id), - actor=request.actor, request_id=request.request_id, contribution=request.contribution, + PostPolicySelection( + project_id=project_id, + guide_id=guide_id, + compilation_id=compilation_id, + policy_id=policy_id, + ), + actor=request.actor, + request_id=request.request_id, + contribution=request.contribution, ) - adapter = TypeAdapter(PostPolicyReviewPackage[CompiledPostSubmitPolicy]) + adapter = TypeAdapter(PostPolicyReviewPackage[LockedExternalPostSubmitPolicy]) response = adapter.validate_json(adapter.dump_json(response, warnings="error")) await request.session.commit() return response @@ -65,13 +88,19 @@ async def read_post_policy( @router.post( - "/approval", response_model=PostPolicyReceipt, - openapi_extra={"x-workstream-action-id": "project.post_submit_checker_policy.approve", - "parameters": [IDEMPOTENCY_PARAMETER]}, + "/approval", + response_model=PostPolicyReceipt, + openapi_extra={ + "x-workstream-action-id": "project.post_submit_checker_policy.approve", + "parameters": [IDEMPOTENCY_PARAMETER], + }, dependencies=[Depends(require_proposal_key)], ) async def approve_post_policy( - project_id: UUID, guide_id: UUID, compilation_id: UUID, policy_id: UUID, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, + policy_id: UUID, payload: PostPolicyApprovalInput, key: Annotated[UUID, Depends(require_proposal_key)], request: Annotated[PostPolicyRequest, Depends(get_post_policy_request)], @@ -81,7 +110,8 @@ async def approve_post_policy( try: response = await request.service.approve( PostPolicyApproval(**payload.model_dump(), idempotency_key=key), - actor=request.actor, request_id=request.request_id, + actor=request.actor, + request_id=request.request_id, ) await request.session.commit() return response @@ -92,13 +122,20 @@ async def approve_post_policy( @router.post( - "/corrections", response_model=PostPolicyReceipt, status_code=201, - openapi_extra={"x-workstream-action-id": "project.post_submit_checker_policy.correction.request", - "parameters": [IDEMPOTENCY_PARAMETER]}, + "/corrections", + response_model=PostPolicyReceipt, + status_code=201, + openapi_extra={ + "x-workstream-action-id": "project.post_submit_checker_policy.correction.request", + "parameters": [IDEMPOTENCY_PARAMETER], + }, dependencies=[Depends(require_proposal_key)], ) async def correct_post_policy( - project_id: UUID, guide_id: UUID, compilation_id: UUID, policy_id: UUID, + project_id: UUID, + guide_id: UUID, + compilation_id: UUID, + policy_id: UUID, payload: PostPolicyCorrectionInput, key: Annotated[UUID, Depends(require_proposal_key)], request: Annotated[PostPolicyRequest, Depends(get_post_policy_request)], @@ -108,7 +145,8 @@ async def correct_post_policy( try: response = await request.service.request_correction( PostPolicyCorrection(**payload.model_dump(), idempotency_key=key), - actor=request.actor, request_id=request.request_id, + actor=request.actor, + request_id=request.request_id, guide_authorization=request.guide_authorization, ) await request.session.commit() diff --git a/backend/app/core/api_controls.py b/backend/app/core/api_controls.py index d68c63673..06152c5f4 100644 --- a/backend/app/core/api_controls.py +++ b/backend/app/core/api_controls.py @@ -56,12 +56,14 @@ def __init__( detail: Any, error_code: str, error_message: str, + error_details: dict[str, Any] | None = None, retryable: bool = False, headers: Mapping[str, str] | None = None, ) -> None: super().__init__(status_code=status_code, detail=detail, headers=headers) self.error_code = error_code self.error_message = error_message + self.error_details = error_details self.retryable = retryable diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6dccebbac..c0d8ee504 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -228,12 +228,12 @@ class Settings(BaseSettings): ge=0, ) artifact_scratch_reservation_ttl_seconds: float = Field( - default=2400.0, + default=3900.0, gt=0.0, le=86_400.0, ) artifact_preparation_total_deadline_seconds: float = Field( - default=1800.0, + default=3400.0, gt=0.0, le=7200.0, ) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index cf90e66ad..f438ad840 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -36,6 +36,7 @@ CheckerRun, CheckerSubmissionFence, ExternalCheckerRegistryEntryRecord, + SelectedPreSubmitRun, ) from app.modules.compensation.models import ( # noqa: F401 CompensationAdapterBindingLifecycleEvent, diff --git a/backend/app/interfaces/artifacts.py b/backend/app/interfaces/artifacts.py index ea182332e..de4347a4e 100644 --- a/backend/app/interfaces/artifacts.py +++ b/backend/app/interfaces/artifacts.py @@ -252,6 +252,12 @@ class ArtifactIntegrityError(ArtifactStoreError): category = "integrity" +class ArtifactScratchIntegrityError(ArtifactIntegrityError): + """Raised when private scratch state violates its filesystem contract.""" + + code = "artifact_scratch_integrity_failure" + + class ArtifactRangeInvalidError(ArtifactStoreError): """Raised when a requested range starts past the object end.""" diff --git a/backend/app/interfaces/project_agents.py b/backend/app/interfaces/project_agents.py index 595471321..cf20300c6 100644 --- a/backend/app/interfaces/project_agents.py +++ b/backend/app/interfaces/project_agents.py @@ -10,7 +10,8 @@ from uuid import UUID from app.modules.checkers.api.artifact_paths import ( - is_canonical_relative_path, is_canonical_relative_pattern, + is_canonical_relative_path, + is_canonical_relative_pattern, ) from pydantic import ( @@ -26,10 +27,9 @@ ) from app.modules.projects.api.task_examples import ProjectGuideTaskExamples -from app.modules.checkers.api import PostSubmitCatalogue, PostSubmitDefinition -from app.modules.checkers.api.pre_submit_catalogue import ( - PreSubmissionCapabilityDefinition, - PreSubmissionCapabilityProjection, +from app.modules.checkers.api.external import ( + ExternalCheckerCapabilityDefinition, + ExternalCheckerCapabilityProjection, ) from app.interfaces.external_services import ( ExternalServiceAdapter, @@ -37,7 +37,11 @@ ExternalServiceConfigurationError, ) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.modules.projects.api.guide_documents import GuideRuntimeCleanupCustody, GuideDocumentManifest, GuideRuntimeCapabilities +from app.modules.projects.api.guide_documents import ( + GuideRuntimeCleanupCustody, + GuideDocumentManifest, + GuideRuntimeCapabilities, +) MAXIMUM_PROJECT_GUIDE_COMPILATION_PROMPT_BYTES = 1_000_000 @@ -78,12 +82,17 @@ ), } ModelProse = Annotated[str, Field(json_schema_extra=_MODEL_PROSE_SCHEMA)] -PolicyIdentifier = Annotated[str, Field(json_schema_extra={ - "pattern": _SAFE_IDENTIFIER.pattern, - "minLength": 1, - "maxLength": 100, - "description": "Canonical machine identifier, not prose or a file path: start with a lowercase letter, then lowercase letters, digits, underscores, dots or hyphens. Example: results or rights_confirmed.", -})] +PolicyIdentifier = Annotated[ + str, + Field( + json_schema_extra={ + "pattern": _SAFE_IDENTIFIER.pattern, + "minLength": 1, + "maxLength": 100, + "description": "Canonical machine identifier, not prose or a file path: start with a lowercase letter, then lowercase letters, digits, underscores, dots or hyphens. Example: results or rights_confirmed.", + } + ), +] def _validated_safe_model_text(value: str) -> str: @@ -131,10 +140,14 @@ class GuideEvidenceRef(BaseModel): sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") start_page: StrictInt | None = Field(default=None, ge=1, le=100_000) end_page: StrictInt | None = Field(default=None, ge=1, le=100_000) - section: str | None = Field(default=None, max_length=200, description=( - "Optional plain-prose section label. Paraphrase headings; do not copy URLs, " - "paths, slash-separated phrases, credentials or executable command tokens." - )) + section: str | None = Field( + default=None, + max_length=200, + description=( + "Optional plain-prose section label. Paraphrase headings; do not copy URLs, " + "paths, slash-separated phrases, credentials or executable command tokens." + ), + ) @field_validator("section") @classmethod @@ -164,12 +177,15 @@ class CapabilityParameter(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) name: str = Field(json_schema_extra={"pattern": _SAFE_IDENTIFIER.pattern}) - value: CapabilityScalar | Annotated[tuple[CapabilityScalar, ...], Field(min_length=1, max_length=50)] + value: ( + CapabilityScalar + | Annotated[tuple[CapabilityScalar, ...], Field(min_length=1, max_length=50)] + ) @field_validator("name") @classmethod def validate_name(cls, value: str) -> str: - """Require a canonical catalogue-owned parameter name.""" + """Require a canonical registered-checker parameter name.""" return _validated_identifier(value) @field_validator("value") @@ -185,7 +201,14 @@ def validate_value( if type(item) not in {str, int, float, bool}: raise ValueError("capability parameter scalar is invalid") if isinstance(item, str): - _validated_safe_model_text(item) + if "/" in item or "\\" in item: + if not is_canonical_relative_pattern(item): + raise ValueError("capability parameter path is invalid") + # Machine paths belong to the image configuration. Keep + # command/credential checks without treating '/' as prose. + _validated_safe_model_text(item.replace("/", " ")) + else: + _validated_safe_model_text(item) elif isinstance(item, (int, float)): if not math.isfinite(item) or abs(item) > 10**12: raise ValueError("capability parameter number is out of range") @@ -208,8 +231,23 @@ def validate_identifiers(cls, value: str) -> str: """Require canonical requirement and capability identity fields.""" return _validated_identifier(value) + class PreSubmissionBindingProposal(_CapabilityBindingIdentity): - """Reference intake capability; its settings live only in the artifact policy.""" + """Reference an external intake image with approved typed configuration.""" + + parameters: tuple[CapabilityParameter, ...] = Field( + default=(), + max_length=50, + exclude_if=lambda value: not value, + ) + + @model_validator(mode="after") + def validate_parameter_names(self) -> PreSubmissionBindingProposal: + """Reject duplicate names without changing retained empty bindings.""" + names = [parameter.name for parameter in self.parameters] + if len(names) != len(set(names)): + raise ValueError("capability parameters must be unique") + return self class PostSubmissionBindingProposal(_CapabilityBindingIdentity): @@ -263,13 +301,15 @@ class AtomicGuideRequirement(BaseModel): requirement_id: str = Field(json_schema_extra={"pattern": _SAFE_IDENTIFIER.pattern}) statement: ModelProse - disposition: RequirementDisposition = Field(description=( - "Use human_review for work assigned to an authorized human reviewer. " - "Use a capability gap only for required unsupported automated evaluation. " - "Any guide_blocker, pre_submit_capability_gap or post_submit_capability_gap " - "requires guide_blocked and no artifact policy. Exact supported binding " - "proposals may remain as catalogue-match evidence without being projected." - )) + disposition: RequirementDisposition = Field( + description=( + "Use human_review for work assigned to an authorized human reviewer. " + "Use a capability gap only for required unsupported automated evaluation. " + "A guide_blocker requires guide_blocked and no artifact policy. Capability " + "gaps remain explicit activation blockers while the manager reviews the " + "available exact bindings in a warning-ready proposal." + ) + ) platform_coverage: PlatformCoverageRef | None = None evidence_refs: tuple[GuideEvidenceRef, ...] = Field( default=(), max_length=MAXIMUM_EVIDENCE_REFS @@ -287,24 +327,29 @@ class SubmissionArtifactPolicyProposal(BaseModel): packaging: Literal["zip"] = "zip" maximum_file_size_bytes: StrictInt = Field(gt=0, le=10 * 1024 * 1024 * 1024) maximum_package_size_bytes: StrictInt = Field( - gt=0, le=10 * 1024 * 1024 * 1024, + gt=0, + le=10 * 1024 * 1024 * 1024, description="Maximum total expanded bytes in the submitted ZIP, verified from actual contents; not compressed upload bytes.", ) maximum_archive_size_bytes: StrictInt | None = Field( - default=None, gt=0, + default=None, + gt=0, description="Maximum verified byte count of the entire submitted compressed ZIP, including archive metadata; not expanded contents or summed compressed member sizes. Null adds no project limit; platform safety limits always apply.", ) maximum_archive_entries: StrictInt | None = Field( - default=None, gt=0, + default=None, + gt=0, description="Maximum normalized outer ZIP entries, counting files and directory entries including implied parent directories. Omitting a directory record cannot evade the limit. Nested archives count as files, not recursively expanded members. Null adds no project limit; platform safety limits always apply.", ) allowed_storage_schemes: tuple[Literal["artifact"], ...] = ("artifact",) required_artifacts: tuple[Annotated[str, Field(min_length=1, max_length=500)], ...] = Field( - default=(), max_length=100, + default=(), + max_length=100, description="Exact canonical relative POSIX paths inside the submitted ZIP. A bare path such as task.toml requires that file at the ZIP root; wrapper/task.toml does not satisfy it. Nested paths such as outputs/answer.md require that exact location. No traversal, storage references or secret files.", ) forbidden_artifacts: tuple[Annotated[str, Field(min_length=1, max_length=500)], ...] = Field( - default=(), max_length=100, + default=(), + max_length=100, description="Relative artifact prohibition patterns, such as secret* or outputs/*.tmp; these are machine fields, not prose.", ) required_evidence: tuple[PolicyIdentifier, ...] = Field(default=(), max_length=100) @@ -314,7 +359,9 @@ class SubmissionArtifactPolicyProposal(BaseModel): @classmethod def validate_required_paths(cls, values: tuple[str, ...]) -> tuple[str, ...]: """Artifact paths are machine fields, not display prose.""" - if len(values) != len(set(values)) or not all(is_canonical_relative_path(value) for value in values): + if len(values) != len(set(values)) or not all( + is_canonical_relative_path(value) for value in values + ): raise ValueError("artifact policy paths must be unique canonical relative paths") return values @@ -322,7 +369,9 @@ def validate_required_paths(cls, values: tuple[str, ...]) -> tuple[str, ...]: @classmethod def validate_forbidden_patterns(cls, values: tuple[str, ...]) -> tuple[str, ...]: """Express prohibition patterns without treating their secret names as requests.""" - if len(values) != len(set(values)) or not all(is_canonical_relative_pattern(value) for value in values): + if len(values) != len(set(values)) or not all( + is_canonical_relative_pattern(value) for value in values + ): raise ValueError("artifact policy patterns must be unique canonical relative patterns") return values @@ -383,10 +432,11 @@ class ProjectGuideCompilationContext(BaseModel): setup_generation: StrictInt = Field(ge=1) instruction_version: str = Field(max_length=100) agent_identity: str = Field(max_length=100) - agent_version: str = Field(max_length=100, json_schema_extra={"pattern": _SAFE_IDENTIFIER.pattern}) + agent_version: str = Field( + max_length=100, json_schema_extra={"pattern": _SAFE_IDENTIFIER.pattern} + ) runtime_configuration: ProjectGuideRuntimeConfiguration - pre_submission_capabilities: PreSubmissionCapabilityProjection - post_submission_capabilities: PostSubmitCatalogue + checker_capabilities: ExternalCheckerCapabilityProjection task_examples: ProjectGuideTaskExamples correction_feedback: ProjectGuideCorrectionFeedback | None = None @@ -394,11 +444,17 @@ class ProjectGuideCompilationContext(BaseModel): def validate_instruction_configuration(self) -> ProjectGuideCompilationContext: """Bind the semantic instruction version to its exact execution snapshot.""" configuration = self.runtime_configuration - if (self.material.setup_run_id != self.setup_run_id - or self.material.setup_generation != self.setup_generation - or len(self.material.documents) > configuration.maximum_documents - or any(item.byte_count > configuration.maximum_document_bytes for item in self.material.documents) - or sum(item.byte_count for item in self.material.documents) > configuration.maximum_total_document_bytes): + if ( + self.material.setup_run_id != self.setup_run_id + or self.material.setup_generation != self.setup_generation + or len(self.material.documents) > configuration.maximum_documents + or any( + item.byte_count > configuration.maximum_document_bytes + for item in self.material.documents + ) + or sum(item.byte_count for item in self.material.documents) + > configuration.maximum_total_document_bytes + ): raise ValueError("compilation document manifest exceeds its execution boundary") if self.instruction_version != self.runtime_configuration.instruction_version: raise ValueError("compilation instruction configuration mismatch") @@ -457,7 +513,9 @@ class ProjectGuideCompilationResult(BaseModel): ) setup_notes: tuple[ModelProse, ...] = Field(default=(), max_length=MAXIMUM_COMPILATION_NOTES) agent_name: Literal["ProjectGuideCompilationAgent"] = "ProjectGuideCompilationAgent" - agent_version: str = Field(max_length=100, json_schema_extra={"pattern": _SAFE_IDENTIFIER.pattern}) + agent_version: str = Field( + max_length=100, json_schema_extra={"pattern": _SAFE_IDENTIFIER.pattern} + ) schema_version: Literal["project_guide_compilation_result.v1"] = ( "project_guide_compilation_result.v1" ) @@ -487,10 +545,11 @@ def validate_project_guide_compilation_result( result: ProjectGuideCompilationResult, ) -> None: """Fail closed when an untrusted result diverges from canonical capability truth.""" - if project_guide_compilation_result_storage_bytes(result) > MAXIMUM_COMPILATION_RESULT_STORAGE_BYTES: + if ( + project_guide_compilation_result_storage_bytes(result) + > MAXIMUM_COMPILATION_RESULT_STORAGE_BYTES + ): raise ValueError("compilation result exceeds storage byte limit") - if not context.pre_submission_capabilities.available: - raise ValueError("pre-submit capability projection is unavailable") requirements = {item.requirement_id: item for item in result.requirements} if len(requirements) != len(result.requirements): raise ValueError("compilation requirements must be unique") @@ -502,18 +561,16 @@ def validate_project_guide_compilation_result( elif result.submission_artifact_policy is None: raise ValueError("draft-ready compilation requires artifact policy") + capabilities = ExternalCheckerCapabilityProjection.model_validate(context.checker_capabilities) pre_definitions = { - definition.stable_id: definition - for definition in context.pre_submission_capabilities.definitions + (definition.capability_id, definition.capability_version): definition + for definition in capabilities.definitions + if definition.phase == "pre_submit" } - post_capabilities = context.post_submission_capabilities - post_capabilities = PostSubmitCatalogue.model_validate(post_capabilities) - if any( - item.platform_default and item.state != "enabled" for item in post_capabilities.definitions - ): - raise ValueError("post-submit capability projection is unavailable") post_definitions = { - definition.capability_id: definition for definition in post_capabilities.definitions + (definition.capability_id, definition.capability_version): definition + for definition in capabilities.definitions + if definition.phase == "post_submit" } _validate_platform_coverage(result.requirements, pre_definitions, post_definitions) _validate_evidence_lineage(context, result) @@ -523,7 +580,7 @@ def validate_project_guide_compilation_result( pre_definitions, "pre_submit", ) - post_bound_requirements = validate_post_submission_bindings(result, post_capabilities) + post_bound_requirements = validate_post_submission_bindings(result, capabilities) expected_pre = { item.requirement_id for item in result.requirements @@ -539,23 +596,29 @@ def validate_project_guide_compilation_result( def validate_post_submission_bindings( - result: ProjectGuideCompilationResult, catalogue: PostSubmitCatalogue, + result: ProjectGuideCompilationResult, + capabilities: ExternalCheckerCapabilityProjection, ) -> set[str]: - """Validate the same post-stage requirement/configuration rules at both boundaries.""" + """Validate POST bindings against the immutable external registry projection.""" result = ProjectGuideCompilationResult.model_validate_json(result.model_dump_json()) - catalogue = PostSubmitCatalogue.model_validate(catalogue) + capabilities = ExternalCheckerCapabilityProjection.model_validate(capabilities) requirements = {item.requirement_id: item for item in result.requirements} if len(requirements) != len(result.requirements): raise ValueError("compilation requirements must be unique") _validate_status_consistency(result) - if any(item.platform_default and item.state != "enabled" for item in catalogue.definitions): - raise ValueError("post-submit capability projection is unavailable") bound = _validate_bindings( - result.post_submit_bindings, requirements, - {item.capability_id: item for item in catalogue.definitions}, "post_submit", + result.post_submit_bindings, + requirements, + { + (item.capability_id, item.capability_version): item + for item in capabilities.definitions + if item.phase == "post_submit" + }, + "post_submit", ) if bound != { - item.requirement_id for item in result.requirements + item.requirement_id + for item in result.requirements if item.disposition is RequirementDisposition.SUPPORTED_POST_SUBMIT }: raise ValueError("supported compilation requirements must have one binding") @@ -564,8 +627,11 @@ def validate_post_submission_bindings( def project_guide_compilation_result_storage_bytes(result: ProjectGuideCompilationResult) -> int: """Measure the default SQLAlchemy PostgreSQL JSON representation, not hash encoding.""" - return len(json.dumps(result.model_dump(mode="json"), ensure_ascii=True, - allow_nan=False).encode("utf-8")) + return len( + json.dumps(result.model_dump(mode="json"), ensure_ascii=True, allow_nan=False).encode( + "utf-8" + ) + ) def _validate_capability_suggestions( @@ -577,13 +643,18 @@ def _validate_capability_suggestions( RequirementDisposition.PRE_SUBMIT_CAPABILITY_GAP: CompilationStage.PRE_SUBMIT, RequirementDisposition.POST_SUBMIT_CAPABILITY_GAP: CompilationStage.POST_SUBMIT, } - gaps = {key: stages[item.disposition] for key, item in requirements.items() - if item.disposition in stages} + gaps = { + key: stages[item.disposition] + for key, item in requirements.items() + if item.disposition in stages + } seen: set[str] = set() for suggestion in suggestions: - if (suggestion.requirement_id in seen - or gaps.get(suggestion.requirement_id) != suggestion.stage - or not suggestion.evidence_refs): + if ( + suggestion.requirement_id in seen + or gaps.get(suggestion.requirement_id) != suggestion.stage + or not suggestion.evidence_refs + ): raise ValueError("compilation capability suggestion is invalid") seen.add(suggestion.requirement_id) if seen != set(gaps): @@ -593,15 +664,12 @@ def _validate_capability_suggestions( def _validate_status_consistency(result: ProjectGuideCompilationResult) -> None: """Require ready and blocked status to match findings and dispositions.""" has_blocker = any(finding.severity == "blocking_gap" for finding in result.findings) or any( - requirement.disposition - in { - RequirementDisposition.GUIDE_BLOCKER, - RequirementDisposition.PRE_SUBMIT_CAPABILITY_GAP, - RequirementDisposition.POST_SUBMIT_CAPABILITY_GAP, - } + requirement.disposition is RequirementDisposition.GUIDE_BLOCKER for requirement in result.requirements ) - has_warning = any(finding.severity == "warning" for finding in result.findings) + has_warning = any(finding.severity == "warning" for finding in result.findings) or bool( + result.capability_suggestions + ) if result.status == "guide_blocked" and not has_blocker: raise ValueError("blocked compilation requires blocking evidence") if result.status != "guide_blocked" and has_blocker: @@ -614,8 +682,8 @@ def _validate_status_consistency(result: ProjectGuideCompilationResult) -> None: def _validate_platform_coverage( requirements: tuple[AtomicGuideRequirement, ...], - pre_definitions: dict[str, PreSubmissionCapabilityDefinition], - post_definitions: dict[str, PostSubmitDefinition], + pre_definitions: dict[tuple[str, str], ExternalCheckerCapabilityDefinition], + post_definitions: dict[tuple[str, str], ExternalCheckerCapabilityDefinition], ) -> None: """Resolve platform coverage only against eligible phase-owner truth.""" for requirement in requirements: @@ -627,24 +695,16 @@ def _validate_platform_coverage( if coverage is None: raise ValueError("platform-covered requirement requires canonical proof") if coverage.stage is CompilationStage.PRE_SUBMIT: - definition = pre_definitions.get(coverage.capability_id) + definition = pre_definitions.get((coverage.capability_id, coverage.capability_version)) valid = ( definition is not None - and definition.version == coverage.capability_version - and definition.dispatch_kind == "platform_capability" - and definition.state == "enabled" - and definition.classification != "advisory" + and definition.capability_id == "workstream.default" and not definition.selectable ) else: - post_definition = post_definitions.get(coverage.capability_id) - valid = ( - post_definition is not None - and post_definition.capability_version == coverage.capability_version - and post_definition.state == "enabled" - and post_definition.platform_default - and not post_definition.selectable - ) + # Workstream owns exactly one built-in default checker and it runs + # before admission. POST capabilities are project-selected images. + valid = False if not valid: raise ValueError("platform coverage does not resolve to canonical truth") @@ -680,7 +740,7 @@ def _validate_evidence_lineage( def _validate_bindings( bindings: tuple[PreSubmissionBindingProposal | PostSubmissionBindingProposal, ...], requirements: dict[str, AtomicGuideRequirement], - definitions: dict[str, PreSubmissionCapabilityDefinition | PostSubmitDefinition], + definitions: dict[tuple[str, str], ExternalCheckerCapabilityDefinition], expected_stage: Literal["pre_submit", "post_submit"], ) -> set[str]: """Validate exact stage, version, selectability, and parameter ownership.""" @@ -692,7 +752,11 @@ def _validate_bindings( ) for binding in bindings: requirement = requirements.get(binding.requirement_id) - definition = definitions.get(binding.capability_id) + definition = definitions.get((binding.capability_id, binding.capability_version)) + if definition is None and any( + capability_id == binding.capability_id for capability_id, _ in definitions + ): + raise ValueError("compilation capability version is stale") if ( requirement is None or requirement.disposition is not expected_disposition @@ -702,24 +766,15 @@ def _validate_bindings( or binding.stage.value != expected_stage ): raise ValueError("compilation capability binding is invalid") - version = ( - definition.version - if isinstance(definition, PreSubmissionCapabilityDefinition) - else definition.capability_version + if definition.registry_entry is None: + raise ValueError("compilation capability binding is invalid") + definition.registry_entry.configuration_schema.validate_instance( + {item.name: item.model_dump(mode="json")["value"] for item in binding.parameters} ) - if binding.capability_version != version: - raise ValueError("compilation capability version is stale") - if isinstance(definition, PostSubmitDefinition): - if not isinstance(binding, PostSubmissionBindingProposal): - raise ValueError("compilation capability binding is invalid") - definition.validate_configuration( - {item.name: item.value for item in binding.parameters} - ) seen_requirements.add(binding.requirement_id) return seen_requirements - class ProjectAgentRuntimeError(ExternalServiceAdapterError): """Raised when a project-agent runtime cannot complete a trusted operation.""" diff --git a/backend/app/main.py b/backend/app/main.py index 0d8c02b1f..a5c509dac 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,10 +35,6 @@ ) from app.core.observability import ObservabilityMiddleware, ObservabilityRuntime from app.interfaces.artifacts import ArtifactStoreBootstrap, ArtifactStoreNamespaceClaim -from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, - parse_disabled_pre_submission_checker_ids, -) MAX_VALIDATION_ERRORS = 20 MAX_VALIDATION_LOCATION_PARTS = 8 @@ -93,11 +89,6 @@ async def _application_lifespan(app: FastAPI) -> AsyncIterator[None]: runtime: ObservabilityRuntime = app.state.observability_runtime runtime.start() try: - app.state.pre_submission_checker_catalogue = build_pre_submission_checker_catalogue( - disabled_entry_ids=parse_disabled_pre_submission_checker_ids( - settings.artifact_pre_submission_checker_disabled_ids - ) - ) if settings.pagination_cursor_hmac_secret is None: raise RuntimeError("pagination cursor HMAC secret is required") decode_pagination_cursor_hmac_secret(settings.pagination_cursor_hmac_secret) @@ -185,7 +176,9 @@ def _json_safe_validation_value(value: Any) -> Any: def _validation_summary(error: dict[str, Any]) -> dict[str, Any]: """Return bounded type/location evidence for the canonical error object.""" raw_type = error.get("type") - error_type = raw_type if isinstance(raw_type, str) and raw_type.isascii() else "validation_error" + error_type = ( + raw_type if isinstance(raw_type, str) and raw_type.isascii() else "validation_error" + ) error_type = error_type[:MAX_VALIDATION_CODE_LENGTH] location: list[int | str] = [] raw_location = error.get("loc") @@ -249,6 +242,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException) code=code, message=message, retryable=retryable, + details=exc.error_details if isinstance(exc, StructuredHTTPException) else None, compatibility={"detail": exc.detail}, headers=exc.headers, ) @@ -298,9 +292,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: settings, service_name="workstream-api", route_templates=route_templates, - export_adapter=create_observability_export_adapter( - settings, service_name="workstream-api" - ), + export_adapter=create_observability_export_adapter(settings, service_name="workstream-api"), ) app.state.observability_runtime = runtime app.add_middleware(RequestContextMiddleware) diff --git a/backend/app/modules/actors/api/service_identities.py b/backend/app/modules/actors/api/service_identities.py index 915a4ec27..9b89804f2 100644 --- a/backend/app/modules/actors/api/service_identities.py +++ b/backend/app/modules/actors/api/service_identities.py @@ -16,6 +16,7 @@ class ServiceIdentity(StrEnum): ARTIFACT_CHECKER_OUTPUT = "workstream.artifact.checker_output" TASK_ASSIGNMENT_RECONCILER = "workstream.task.assignment_reconciler" TASK_POST_SUBMIT_ROUTER = "workstream.task.post_submit_router" + CHECKER_PRE_SUBMIT = "workstream.checker.pre_submit" CHECKER_POST_SUBMIT = "workstream.checker.post_submit" OUTBOX_DISPATCHER = "workstream.outbox.dispatcher" PROJECT_SETUP = "workstream.project.setup" diff --git a/backend/app/modules/artifacts/api/submission_admission.py b/backend/app/modules/artifacts/api/submission_admission.py index 8b1a346d0..226d34d84 100644 --- a/backend/app/modules/artifacts/api/submission_admission.py +++ b/backend/app/modules/artifacts/api/submission_admission.py @@ -7,6 +7,8 @@ from uuid import UUID import re +from app.core.hashing import canonical_json_hash + from app.modules.tasks.api import TaskSubmissionContextFacts SubmissionAdmissionConsumptionStatus = Literal["consumed", "stale"] @@ -123,6 +125,8 @@ class SubmissionAdmissionConsumptionResult: replayed: bool material: SubmissionAdmissionMaterial | None binding_decision_id: UUID | None + selected_post_submit_policy_sha256: str | None = None + selected_post_submit_policy_body: dict[str, object] | None = None def __post_init__(self) -> None: if self.status == "consumed": @@ -130,6 +134,23 @@ def __post_init__(self) -> None: raise ValueError("consumed admission requires verified material") elif self.status != "stale" or self.material is not None or self.binding_id is not None: raise ValueError("stale admission has no material") + if (self.selected_post_submit_policy_sha256 is None) != ( + self.selected_post_submit_policy_body is None + ): + raise ValueError("selected POST policy custody is incomplete") + if self.selected_post_submit_policy_body is not None and ( + self.selected_post_submit_policy_body.get("policy_sha256") + != self.selected_post_submit_policy_sha256 + or canonical_json_hash( + { + key: value + for key, value in self.selected_post_submit_policy_body.items() + if key != "policy_sha256" + } + ) + != self.selected_post_submit_policy_sha256 + ): + raise ValueError("selected POST policy custody is invalid") class SubmissionAdmissionConsumptionPort(Protocol): diff --git a/backend/app/modules/artifacts/api/submission_preparation.py b/backend/app/modules/artifacts/api/submission_preparation.py index 1d1cbc0d1..b2a1ad20a 100644 --- a/backend/app/modules/artifacts/api/submission_preparation.py +++ b/backend/app/modules/artifacts/api/submission_preparation.py @@ -9,7 +9,7 @@ from uuid import UUID from app.modules.authorization.api import ActorIdentityFacts -from app.modules.checkers.api import PreSubmissionExecutionFacts +from app.modules.checkers.api.selected_pre_submit import SelectedPreSubmitExecutionFacts class SubmissionBundlePreparationRejected(RuntimeError): @@ -19,14 +19,54 @@ class SubmissionBundlePreparationRejected(RuntimeError): class SubmissionBundlePreparationCheckFailed(SubmissionBundlePreparationRejected): """Carry the exact bounded CHECKERS facts for one blocked preparation.""" - def __init__(self, facts: PreSubmissionExecutionFacts) -> None: - if type(facts) is not PreSubmissionExecutionFacts: + def __init__( + self, + facts: SelectedPreSubmitExecutionFacts, + ) -> None: + if type(facts) is not SelectedPreSubmitExecutionFacts: raise TypeError("pre-submission feedback facts are invalid") if facts.eligible is not False: raise ValueError("blocked pre-submission facts require eligible false") super().__init__("pre_submission_checker_failed") self.facts = facts + def public_results(self) -> list[dict[str, object]]: + """Return sanitized ordered work findings without execution authority.""" + default = self.facts.workstream_default + results: list[dict[str, object]] = [ + { + "source": "workstream_default", + "check_id": item.check_id, + "status": item.status, + "failure_code": item.failure_code, + "finding_count": item.finding_count, + } + for item in default.checks + ] + results.extend( + { + "source": "workstream_default", + "status": "warning", + "warning_code": item.code, + } + for item in default.warnings + ) + results.extend( + { + "source": "project_external_checker", + "requirement_id": member.requirement_id, + "registry_entry_id": str(member.registry_entry_id), + "registry_entry_digest": member.registry_entry_digest, + "status": member.result.verdict, + "findings": [ + finding.model_dump(mode="json") + for finding in member.result.findings + ], + } + for member in self.facts.external_members + ) + return results + class SubmissionBundlePreparationUnavailable(RuntimeError): """Conceal unavailable or denied preparation authority.""" diff --git a/backend/app/modules/artifacts/models.py b/backend/app/modules/artifacts/models.py index 534f4a6ef..107cb1c60 100644 --- a/backend/app/modules/artifacts/models.py +++ b/backend/app/modules/artifacts/models.py @@ -1223,7 +1223,7 @@ class ArtifactPutAttempt(Base): class SubmissionBundleDurableIntent(Base): - """Immutable join fencing one passing evidence set to one provider intent.""" + """Immutable join fencing one legacy or selected PRE result to provider intent.""" __tablename__ = "submission_bundle_durable_intents" __table_args__ = ( @@ -1232,18 +1232,47 @@ class SubmissionBundleDurableIntent(Base): "pre_submit_evidence_set_id", name="uq_submission_bundle_intent_evidence", ), + UniqueConstraint( + "selected_pre_submit_run_id", + name="uq_submission_bundle_intent_selected_run", + ), UniqueConstraint( "put_attempt_id", name="uq_submission_bundle_intent_put_attempt", ), + CheckConstraint( + "((pre_submit_evidence_set_id is not null)::int + " + "(selected_pre_submit_run_id is not null)::int) = 1", + name="pre_submit_lineage", + ), + CheckConstraint( + "(pre_submit_evidence_set_id is not null and selected_request_json is null " + "and selected_request_digest is null and selected_terminal_json is null " + "and selected_terminal_digest is null) or " + "(selected_pre_submit_run_id is not null " + "and jsonb_typeof(selected_request_json)='object' " + "and selected_request_digest ~ '^sha256:[0-9a-f]{64}$' " + "and jsonb_typeof(selected_terminal_json)='object' " + "and selected_terminal_digest ~ '^sha256:[0-9a-f]{64}$')", + name="selected_snapshot", + ), ) id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) - pre_submit_evidence_set_id: Mapped[str] = mapped_column( + pre_submit_evidence_set_id: Mapped[str | None] = mapped_column( ForeignKey("pre_submit_evidence_sets.id", ondelete="RESTRICT"), - nullable=False, + nullable=True, index=True, ) + selected_pre_submit_run_id: Mapped[str | None] = mapped_column( + ForeignKey("selected_pre_submit_runs.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + selected_request_json: Mapped[dict | None] = mapped_column(JSONB) + selected_request_digest: Mapped[str | None] = mapped_column(String(71)) + selected_terminal_json: Mapped[dict | None] = mapped_column(JSONB) + selected_terminal_digest: Mapped[str | None] = mapped_column(String(71)) put_attempt_id: Mapped[str] = mapped_column( ForeignKey("artifact_put_attempts.id", ondelete="RESTRICT"), nullable=False, @@ -1264,6 +1293,9 @@ class SubmissionBundleAdmission(Base): UniqueConstraint( "pre_submit_evidence_set_id", name="uq_submission_bundle_admission_evidence" ), + UniqueConstraint( + "selected_pre_submit_run_id", name="uq_submission_bundle_admission_selected_run" + ), UniqueConstraint( "verification_receipt_id", name="uq_submission_bundle_admission_verification" ), @@ -1274,6 +1306,11 @@ class SubmissionBundleAdmission(Base): postgresql_where=text("consumed_by_submission_id is not null"), ), CheckConstraint("status in ('ready','consumed','stale')", name="status"), + CheckConstraint( + "((pre_submit_evidence_set_id is not null)::int + " + "(selected_pre_submit_run_id is not null)::int) = 1", + name="pre_submit_lineage", + ), CheckConstraint( SHA256_CHECK.format(column="locked_policy_context_hash"), name="policy_context_hash", @@ -1281,6 +1318,60 @@ class SubmissionBundleAdmission(Base): CheckConstraint( SHA256_CHECK.format(column="semantic_manifest_sha256"), name="manifest_sha256" ), + CheckConstraint( + "(selected_pre_submit_run_id is null and semantic_manifest_body is null) or " + "(selected_pre_submit_run_id is not null and " + "jsonb_typeof(semantic_manifest_body)='object')", + name="manifest_body", + ), + CheckConstraint( + "(selected_pre_submit_run_id is null and packet_sha256 is null) or " + "(selected_pre_submit_run_id is not null and " + + SHA256_CHECK.format(column="packet_sha256") + + ")", + name="packet_sha256", + ), + CheckConstraint( + "(selected_pre_submit_run_id is null and effective_plan_sha256 is null) or " + "(selected_pre_submit_run_id is not null and " + + SHA256_CHECK.format(column="effective_plan_sha256") + + ")", + name="effective_plan_sha256", + ), + CheckConstraint( + "(selected_pre_submit_run_id is null and selected_rules_sha256 is null) or " + "(selected_pre_submit_run_id is not null and " + + SHA256_CHECK.format(column="selected_rules_sha256") + + ")", + name="selected_rules_sha256", + ), + CheckConstraint( + "(selected_pre_submit_run_id is null and " + "selected_post_submit_policy_sha256 is null and " + "selected_post_submit_policy_body is null) or " + "(selected_pre_submit_run_id is not null and " + + SHA256_CHECK.format(column="selected_post_submit_policy_sha256") + + " and jsonb_typeof(selected_post_submit_policy_body)='object')", + name="selected_post_submit_policy", + ), + CheckConstraint( + "(selected_pre_submit_run_id is null and guide_id is null and " + "guide_version is null and source_snapshot_id is null and " + "source_snapshot_sha256 is null and effective_policy_id is null and " + "effective_policy_sha256 is null and pre_submit_policy_id is null and " + "pre_submit_policy_sha256 is null) or " + "(selected_pre_submit_run_id is not null and guide_id is not null and " + "octet_length(guide_version) between 1 and 50 and " + "source_snapshot_id is not null and effective_policy_id is not null and " + "pre_submit_policy_id is not null and " + + SHA256_CHECK.format(column="source_snapshot_sha256") + + " and " + + SHA256_CHECK.format(column="effective_policy_sha256") + + " and " + + SHA256_CHECK.format(column="pre_submit_policy_sha256") + + ")", + name="locked_policy_digests", + ), CheckConstraint(SHA256_CHECK.format(column="archive_sha256"), name="archive_sha256"), CheckConstraint("archive_byte_count >= 0", name="archive_size"), CheckConstraint( @@ -1314,8 +1405,11 @@ class SubmissionBundleAdmission(Base): durable_intent_id: Mapped[str] = mapped_column( ForeignKey("submission_bundle_durable_intents.id", ondelete="RESTRICT"), nullable=False ) - pre_submit_evidence_set_id: Mapped[str] = mapped_column( - ForeignKey("pre_submit_evidence_sets.id", ondelete="RESTRICT"), nullable=False, index=True + pre_submit_evidence_set_id: Mapped[str | None] = mapped_column( + ForeignKey("pre_submit_evidence_sets.id", ondelete="RESTRICT"), nullable=True, index=True + ) + selected_pre_submit_run_id: Mapped[str | None] = mapped_column( + ForeignKey("selected_pre_submit_runs.id", ondelete="RESTRICT"), nullable=True, index=True ) put_attempt_id: Mapped[str] = mapped_column( ForeignKey("artifact_put_attempts.id", ondelete="RESTRICT"), nullable=False @@ -1360,8 +1454,24 @@ class SubmissionBundleAdmission(Base): name="fk_submission_bundle_admissions_checker_remediation_manifest"), ) locked_policy_context_hash: Mapped[str] = mapped_column(String(71), nullable=False) + guide_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + guide_version: Mapped[str | None] = mapped_column(String(50)) + source_snapshot_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + source_snapshot_sha256: Mapped[str | None] = mapped_column(String(71)) + effective_policy_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + effective_policy_sha256: Mapped[str | None] = mapped_column(String(71)) + pre_submit_policy_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + pre_submit_policy_sha256: Mapped[str | None] = mapped_column(String(71)) + effective_plan_sha256: Mapped[str | None] = mapped_column(String(71)) + selected_rules_sha256: Mapped[str | None] = mapped_column(String(71)) + selected_post_submit_policy_sha256: Mapped[str | None] = mapped_column( + String(71) + ) + selected_post_submit_policy_body: Mapped[dict | None] = mapped_column(JSONB) + packet_sha256: Mapped[str | None] = mapped_column(String(71)) semantic_manifest_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) semantic_manifest_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + semantic_manifest_body: Mapped[dict | None] = mapped_column(JSONB) archive_sha256: Mapped[str] = mapped_column(String(71), nullable=False) archive_byte_count: Mapped[int] = mapped_column(BigInteger, nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready", index=True) diff --git a/backend/app/modules/artifacts/post_submit_materialization.py b/backend/app/modules/artifacts/post_submit_materialization.py index 7474bcc84..cd7caa939 100644 --- a/backend/app/modules/artifacts/post_submit_materialization.py +++ b/backend/app/modules/artifacts/post_submit_materialization.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Callable +from contextlib import contextmanager from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -10,6 +11,7 @@ ArtifactStore, ArtifactObjectMissingError, ArtifactInputMismatchError, ArtifactIntegrityError, ArtifactLimitExceededError, ArtifactStoreUnavailableError, ) +from app.interfaces.external_checker_execution import ExternalCheckerMaterialGrant from app.modules.checkers.api.materialization import ( PostSubmissionMaterialConsumer, PostSubmissionMaterializationResult, PostSubmissionMaterializationUnavailable, PostSubmissionMaterializationFailure, @@ -19,24 +21,41 @@ select_post_submission_material, ) from app.modules.artifacts.preparation import ArtifactPreparationService, ArtifactScratchIntegrityError +from app.modules.artifacts.preparation import ( + ExternalMaterialFile, + PostExternalMaterialGrantRequest, +) from app.modules.artifacts.service import ArtifactStorageNamespaceError, ArtifactStorageNamespaceSpec from app.modules.artifacts.submission_archive import ( - SealedSubmissionTree, SubmissionArchiveInspector, SubmissionArchiveEntryType, - SubmissionArchiveRejectedError, + SealedSubmissionTree, SubmissionArchiveInspector, SubmissionArchiveRejectedError, ) from app.modules.artifacts.submission_manifest import build_submission_manifest -from app.modules.checkers.api import PostSubmissionEvaluationResult +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionResult, + validate_execution_result, +) from app.modules.checkers.api.execution import ( ExecuteFacts, CurrentExecutionPort, VerifiedMaterialFacts, CheckerExecutionUnavailable, ) from app.modules.tasks.api.submitted_bundle import SubmittedBundlePort, SubmittedBundleUnavailable +from app.modules.checkers.api.external import ( + ExternalCheckerExecutionRequest, + PostSubmitExternalCheckerIdentity, + PostSubmitExternalCheckerMaterial, +) class _MaterialView: """Revoke callback access independently of the private projection lifetime.""" - def __init__(self, tree: SealedSubmissionTree) -> None: + def __init__( + self, tree: SealedSubmissionTree, *, preparation, prepared, workspace, + execution: ExecuteFacts, materialization: MaterializationFacts, media_type: str, + ) -> None: """Wrap the single ART-owned sealed tree without exposing its path.""" self._tree = tree + self._preparation, self._prepared, self._workspace = preparation, prepared, workspace + self._execution, self._materialization = execution, materialization + self._media_type = media_type self._closed = False def close(self) -> None: @@ -61,6 +80,113 @@ def read_file(self, normalized_path: str, *, maximum_bytes: int) -> bytes: self._require_open() return self._tree.read_file(normalized_path, maximum_bytes=maximum_bytes) + @contextmanager + def external_checker_grant(self, request: ExternalCheckerExecutionRequest): + """Bind a POST service grant to actual current execution and ART ancestry.""" + self._require_open() + checked = ExternalCheckerExecutionRequest.model_validate(request) + identity = checked.identity + if not isinstance(identity, PostSubmitExternalCheckerIdentity): + raise ArtifactScratchIntegrityError("external POST request identity differs") + execution, facts = self._execution, self._materialization + source = execution.request + lease = execution.lease + reservation = lease.reservation + if ( + identity.project_id != source.project_id + or identity.task_id != source.task_id + or identity.assignment_id != source.assignment_id + or identity.submission_id != source.submission_id + or identity.submission_version != source.submission_version + or identity.evaluation_request_id != source.evaluation_request_id + or identity.evaluation_request_digest != source.request_sha256 + or identity.evaluation_generation != source.evaluation_generation + or identity.attempt_id != reservation.attempt_id + or identity.result_id != reservation.result_id + or identity.lease_id != lease.lease_id + or identity.lease_generation != lease.lease_generation + or identity.lease_expires_at != lease.expires_at + or len(checked.materials) != 1 + or not isinstance(checked.materials[0], PostSubmitExternalCheckerMaterial) + ): + raise ArtifactScratchIntegrityError("external POST execution identity differs") + material = checked.materials[0] + expected = facts.material + if ( + material.submission_id != expected.submission_id + or material.submission_version != expected.submission_version + or material.binding_id != expected.binding_id + or material.content_id != expected.content_id + or material.replica_id != expected.replica_id + or material.content_sha256 != expected.content_sha256 + or material.byte_count != expected.byte_count + or material.evidence_id != facts.evidence_id + or material.verification_receipt_id != facts.verification_receipt_id + or material.verification_job_id != facts.verification_job_id + or material.verification_generation != facts.verification_generation + or material.namespace_fingerprint != facts.namespace_fingerprint + or material.semantic_manifest_sha256 != expected.semantic_manifest_sha256 + ): + raise ArtifactScratchIntegrityError("external POST material identity differs") + entries = self.entries + directories = tuple(item.normalized_path for item in entries if item.entry_type == "directory") + files = tuple(ExternalMaterialFile( + normalized_path=item.normalized_path, byte_count=item.byte_count, + sha256=item.sha256 or "", executable=bool(item.executable), + ) for item in entries if item.entry_type == "file") + lease_expires_at = identity.model_dump(mode="json")["lease_expires_at"] + grant_request = PostExternalMaterialGrantRequest( + request_digest=checked.request_digest, + submission_id=str(identity.submission_id), + submission_version=identity.submission_version, + evaluation_request_id=str(identity.evaluation_request_id), + evaluation_request_digest=identity.evaluation_request_digest, + evaluation_generation=identity.evaluation_generation, + attempt_id=str(identity.attempt_id), result_id=str(identity.result_id), + lease_id=str(identity.lease_id), lease_generation=identity.lease_generation, + lease_expires_at=lease_expires_at, + binding_id=str(material.binding_id), content_id=str(material.content_id), + replica_id=str(material.replica_id), content_sha256=material.content_sha256, + byte_count=material.byte_count, evidence_id=str(material.evidence_id), + verification_receipt_id=str(material.verification_receipt_id), + verification_job_id=str(material.verification_job_id), + verification_generation=material.verification_generation, + namespace_fingerprint=material.namespace_fingerprint, + semantic_manifest_sha256=material.semantic_manifest_sha256, + directories=directories, files=files, + ) + with self._preparation.external_material_grant( + self._prepared, self._workspace, grant_request + ) as grant: + yield ExternalCheckerMaterialGrant( + grant_id=grant.grant_id, + binding_digest=grant.binding_digest, + ) + + @property + def external_checker_material(self) -> PostSubmitExternalCheckerMaterial: + """Detach the exact verified POST lineage selected before byte access.""" + self._require_open() + facts = self._materialization + material = facts.material + return PostSubmitExternalCheckerMaterial( + role="submission_archive", + submission_id=material.submission_id, + submission_version=material.submission_version, + 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, + media_type=self._media_type, + evidence_id=facts.evidence_id, + verification_receipt_id=facts.verification_receipt_id, + verification_job_id=facts.verification_job_id, + verification_generation=facts.verification_generation, + namespace_fingerprint=facts.namespace_fingerprint, + semantic_manifest_sha256=material.semantic_manifest_sha256, + ) + def __reduce__(self): """Prevent transfer of a process-local read capability.""" raise TypeError("submission material view is process-local") @@ -68,12 +194,18 @@ def __reduce__(self): class _MaterialProcessor: """Keep async evaluation inside the shared projection and scratch lifetime.""" - def __init__(self, inspector, inspection, request, consumer) -> None: + def __init__( + self, inspector, inspection, request, consumer, *, preparation, prepared, + execution, materialization, media_type, + ) -> None: """Bind one inspection, request and consumer for preparation-owned execution.""" self._inspector, self._inspection = inspector, inspection self._request, self._consumer = request, consumer + self._preparation, self._prepared = preparation, prepared + self._execution, self._materialization = execution, materialization + self._media_type = media_type self._aborted = False - self._consumer_task: asyncio.Task[PostSubmissionEvaluationResult] | None = None + self._consumer_task: asyncio.Task[PostSubmissionExecutionResult] | None = None self._view: _MaterialView | None = None def abort(self) -> None: @@ -84,7 +216,7 @@ def abort(self) -> None: if self._consumer_task is not None: self._consumer_task.cancel() - async def process(self, reader, workspace) -> PostSubmissionEvaluationResult: + async def process(self, reader, workspace) -> PostSubmissionExecutionResult: """Project off-loop, validate callback output, and drain projection cleanup.""" projection = self._inspector._projected_tree(reader, workspace, expected=self._inspection) # The preparation owner shields and drains this entire operation, including @@ -93,9 +225,13 @@ async def process(self, reader, workspace) -> PostSubmissionEvaluationResult: try: if self._aborted: raise asyncio.CancelledError - self._view = _MaterialView(tree) + self._view = _MaterialView( + tree, preparation=self._preparation, prepared=self._prepared, + workspace=workspace, execution=self._execution, + materialization=self._materialization, media_type=self._media_type, + ) self._consumer_task = asyncio.create_task(self._consumer.evaluate(self._request, self._view)) - result = PostSubmissionEvaluationResult.model_validate(await self._consumer_task) + result = validate_execution_result(await self._consumer_task) result.validate_request(self._request) return result finally: @@ -188,16 +324,19 @@ async def materialize( ) inspection = await prepared.inspect(self._inspector) manifest = build_submission_manifest(inspection) - expected_files = tuple((entry.normalized_path, entry.sha256, entry.byte_count) - for entry in manifest.entries - if entry.entry_type is SubmissionArchiveEntryType.FILE) - supplied_files = tuple(sorted((entry.artifact, entry.hash, entry.size_bytes) - for entry in request.structural_input.manifest)) - if manifest.sha256 != selected.semantic_manifest_sha256 or supplied_files != expected_files: + if manifest.sha256 != selected.semantic_manifest_sha256: raise PostSubmissionMaterializationFailure("post_submit_material_manifest_mismatch") + materialization_facts = self._authority_facts(facts, selected) evaluation = await self._preparation._process_prepared_submission( - prepared, _MaterialProcessor(self._inspector, inspection, request, consumer), - reserved_bytes=manifest.total_expanded_bytes, maximum_entries=manifest.entry_count, + prepared, _MaterialProcessor( + self._inspector, inspection, request, consumer, + preparation=self._preparation, prepared=prepared, + execution=facts, materialization=materialization_facts, + media_type=selected.media_type, + ), + reserved_bytes=manifest.total_expanded_bytes, + # ART's external grant adds one owned mount around the tree. + maximum_entries=manifest.entry_count + 1, ) finally: if prepared is not None: diff --git a/backend/app/modules/artifacts/post_submit_selection.py b/backend/app/modules/artifacts/post_submit_selection.py index 4e01cada7..c98092db5 100644 --- a/backend/app/modules/artifacts/post_submit_selection.py +++ b/backend/app/modules/artifacts/post_submit_selection.py @@ -1,6 +1,6 @@ """ART-owned exact admitted-material selection, without provider I/O or locks.""" -from dataclasses import asdict, dataclass +from dataclasses import dataclass from uuid import UUID from sqlalchemy import select @@ -10,7 +10,7 @@ from app.modules.checkers.api.materialization import PostSubmissionMaterializationUnavailable from app.modules.artifacts.models import ( ArtifactBinding, ArtifactContent, ArtifactReplica, ArtifactStorageNamespace, - ArtifactVerificationJob, ArtifactVerificationReceipt, PreSubmitEvidenceSet, + ArtifactVerificationJob, ArtifactVerificationReceipt, SubmissionBundleAdmission, ) from app.modules.artifacts.service import ( @@ -18,7 +18,9 @@ ) from app.modules.artifacts.sources import ArtifactCommitment from app.modules.artifacts.submission_bindings import SubmissionAdmissionConsumptionService -from app.modules.checkers.api import PostSubmissionEvaluationRequest +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, +) from app.modules.tasks.api.submitted_bundle import ( SubmittedBundleFacts, SubmittedBundlePort, SubmittedBundleRequest, ) @@ -47,29 +49,55 @@ class PostSubmissionMaterialSelection: async def select_post_submission_material( session: AsyncSession, *, tasks: SubmittedBundlePort, - request: PostSubmissionEvaluationRequest, namespace: ArtifactStorageNamespaceSpec, + request: PostSubmissionExecutionRequest, namespace: ArtifactStorageNamespaceSpec, store: ArtifactStore, ) -> PostSubmissionMaterialSelection: """Read fresh exact owner facts in one short caller-owned transaction.""" facts = await tasks.read(SubmittedBundleRequest( project_id=request.project_id, task_id=request.task_id, submission_id=request.submission_id, )) + context = facts.context + context_matches = ( + context.guide_version == request.expected_context.guide_version + and context.source_id == request.expected_context.source_id + and context.source_hash == request.expected_context.source_hash + and context.effective_policy_id + == request.expected_context.effective_policy_id + and context.effective_policy_hash + == request.expected_context.effective_policy_hash + and context.pre_policy_id == request.expected_context.pre_policy_id + and context.pre_policy_hash == request.expected_context.pre_policy_hash + and context.post_policy_id + == request.expected_context.post_policy_id + and context.post_policy_version + == request.expected_context.post_policy_version + and context.post_policy_hash + == request.expected_context.post_policy_hash + and context.selected_post_policy_hash + == request.expected_context.post_policy_hash + and context.selected_post_policy_body == request.policy.model_dump(mode="json") + and context.review_policy_id == request.expected_context.review_policy_id + and context.review_generation == request.expected_context.review_generation + and context.review_hash == request.expected_context.review_hash + and context.revision_policy_id == request.expected_context.revision_policy_id + and context.revision_generation + == request.expected_context.revision_generation + and context.revision_hash == request.expected_context.revision_hash + ) if ( facts.status != "submitted" or (facts.project_id, facts.task_id, facts.assignment_id, facts.submission_id, facts.submission_version, facts.binding_id, facts.content_id) != ( request.project_id, request.task_id, request.assignment_id, request.submission_id, request.submission_version, request.binding_id, request.content_id) - or asdict(facts.context) != request.expected_context.model_dump() - or asdict(facts.context) != request.structural_input.observed_context.model_dump() + or not context_matches ): raise PostSubmissionMaterializationUnavailable("post_submit_material_identity_mismatch") row = (await session.execute( - select(SubmissionBundleAdmission, PreSubmitEvidenceSet, ArtifactBinding, + select(SubmissionBundleAdmission, ArtifactBinding, ArtifactContent, ArtifactReplica, ArtifactVerificationReceipt, ArtifactVerificationJob, ArtifactStorageNamespace) .select_from(SubmissionBundleAdmission) - .join(PreSubmitEvidenceSet, PreSubmitEvidenceSet.id == SubmissionBundleAdmission.pre_submit_evidence_set_id) .join(ArtifactBinding, ArtifactBinding.id == str(facts.binding_id)) .join(ArtifactContent, ArtifactContent.id == SubmissionBundleAdmission.artifact_content_id) .join(ArtifactReplica, ArtifactReplica.id == SubmissionBundleAdmission.verified_replica_id) @@ -83,10 +111,17 @@ async def select_post_submission_material( )).one_or_none() if row is None: raise PostSubmissionMaterializationUnavailable("post_submit_material_unavailable") - admission, evidence, binding, content, replica, receipt, job, persisted = row - context = facts.context + admission, binding, content, replica, receipt, job, persisted = row + selected_lineage = ( + admission.selected_pre_submit_run_id is not None + and admission.pre_submit_evidence_set_id is None + and admission.selected_post_submit_policy_sha256 + == request.policy.policy_sha256 + and admission.selected_post_submit_policy_body + == request.policy.model_dump(mode="json") + ) if not ( - SubmissionAdmissionConsumptionService._art_lineage_is_intact(admission, evidence, content) + SubmissionAdmissionConsumptionService._art_lineage_is_intact(admission, content) and admission.status == "consumed" and admission.consumed_by_submission_id == str(facts.submission_id) and admission.consumed_by_submission_version == facts.submission_version @@ -100,16 +135,10 @@ async def select_post_submission_material( and binding.logical_role == "submission_bundle_original" and binding.scope_version == 1 and binding.content_id == content.id == str(facts.content_id) + and selected_lineage and content.media_type == "application/zip" - and content.sha256 == request.content_sha256 == request.structural_input.package_hash + and content.sha256 == request.content_sha256 and content.byte_count == request.byte_count - and evidence.guide_version == context.guide_version - and evidence.source_snapshot_id == str(context.source_id) - and evidence.source_snapshot_sha256 == context.source_hash - and evidence.effective_policy_id == str(context.effective_policy_id) - and evidence.locked_artifact_policy_sha256 == context.effective_policy_hash - and evidence.pre_submit_policy_id == str(context.pre_policy_id) - and evidence.locked_checker_policy_sha256 == context.pre_policy_hash and replica.content_id == content.id and (replica.verification_state, replica.availability_state, replica.integrity_state) == ("verified", "available", "valid") @@ -127,7 +156,9 @@ async def select_post_submission_material( if replica.provider_object_ref != artifact_provider_object_ref(commitment): raise PostSubmissionMaterializationUnavailable("post_submit_material_identity_mismatch") return PostSubmissionMaterialSelection( - submission=facts, evidence_id=UUID(evidence.id), replica_id=UUID(replica.id), + submission=facts, + evidence_id=UUID(admission.selected_pre_submit_run_id), + replica_id=UUID(replica.id), verification_receipt_id=UUID(receipt.id), verification_job_id=UUID(job.id), verification_generation=receipt.execution_generation, namespace_fingerprint=persisted.namespace_fingerprint, adapter=replica.adapter, diff --git a/backend/app/modules/artifacts/pre_submit_attempts.py b/backend/app/modules/artifacts/pre_submit_attempts.py deleted file mode 100644 index 3a337fee6..000000000 --- a/backend/app/modules/artifacts/pre_submit_attempts.py +++ /dev/null @@ -1,373 +0,0 @@ -"""ART invocation custody; completed outcomes live only in canonical evidence.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from dataclasses import asdict, dataclass, replace -from uuid import UUID, uuid4 -from app.core.identifiers import new_record_id - -from sqlalchemy import select, update -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.hashing import canonical_json_hash -from app.modules.artifacts.submission_manifest import SubmissionManifest -from app.modules.artifacts.models import ( - PreSubmitEvidenceResult, - PreSubmitEvidenceSet, - PreSubmitExecutionAttempt, -) -from app.modules.artifacts.pre_submit_evidence import ( - PersistedPreSubmitEvidence, - PreSubmitEvidenceConflict, - PreSubmitEvidenceContext, - PreSubmitEvidencePersistenceResult, - PreSubmitExecutionCustody, - PreSubmitExecutionResult, - _PreSubmitEvidenceRepository, - _validate_execution, - pre_submit_failure_audit_payload, -) -from app.modules.checkers.api import ( - EffectivePreSubmissionExecutionPlan, - SubmissionPacketView, - PreSubmissionExecutionEntryFacts, - PreSubmissionExecutionFacts, -) - -if TYPE_CHECKING: - from app.modules.artifacts.submission_materialization import ( - PreparedBundleMaterializationRequest, - ) - from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidencePersistenceRequest - - -def logical_request( - context: PreSubmitEvidenceContext, - plan: EffectivePreSubmissionExecutionPlan, - packet: SubmissionPacketView, -) -> dict[str, object]: - """Hash logical input without substituting a retry generation for original custody.""" - if canonical_json_hash(plan.as_dict()) != plan.plan_sha256: - raise PreSubmitEvidenceConflict("pre_submit_attempt_plan_invalid") - values = { - key: str(value) if isinstance(value, UUID) else value - for key, value in asdict(context).items() - if key != "prepared_generation_id" - } - return { - **values, - "effective_plan_sha256": plan.plan_sha256, - "packet_sha256": packet.sha256, - } - - -@dataclass(frozen=True, slots=True) -class _Reservation: - attempt_id: UUID - claim_nonce: UUID - request_digest: str - request: PreparedBundleMaterializationRequest - - -class PreSubmitAttemptClaim: - """A single original invocation, minted only after its reservation commits.""" - - __slots__ = ( - "attempt_id", - "request_digest", - "_nonce", - "_request", - "_started", - "_execution", - "_session", - ) - - def __init__(self, *args: object, **kwargs: object) -> None: - """Reject caller-created invocation authority.""" - raise TypeError("pre-submit claims are issued by ART") - - def __reduce__(self): - """Keep the winning invocation bound to this process and session.""" - raise TypeError("pre-submit claims cannot be serialized") - - @property - def packet_sha256(self) -> str: - """Capture the packet actually passed to this claim's checker invocation.""" - return self._request.packet.sha256 - - async def _reserved( - self, - session: AsyncSession, - *, - lock: bool = False, - ) -> PreSubmitExecutionAttempt: - """Check committed invocation facts in the issuing root session.""" - if ( - session is not self._session - or not session.in_transaction() - or session.in_nested_transaction() - ): - raise PreSubmitEvidenceConflict("pre_submit_attempt_claim_invalid") - statement = ( - select(PreSubmitExecutionAttempt) - .where( - PreSubmitExecutionAttempt.id == str(self.attempt_id), - ) - .execution_options(populate_existing=True) - ) - if lock: - statement = statement.with_for_update() - row = await session.scalar(statement) - if ( - row is None - or row.status != "reserved" - or row.evidence_set_id is not None - or row.claim_nonce != str(self._nonce) - or row.request_digest != self.request_digest - or row.prepared_generation_id != str(self._request.prepared_artifact.generation_id) - ): - raise PreSubmitEvidenceConflict("pre_submit_attempt_claim_invalid") - return row - - async def consume( - self, - session: AsyncSession, - request: PreparedBundleMaterializationRequest, - ) -> None: - """Fence direct materializer calls before constructing the CHECKER processor.""" - if self._started or replace(request, prepared_authorization=None) != self._request: - raise PreSubmitEvidenceConflict("pre_submit_attempt_claim_invalid") - self._started = True - await self._reserved(session) - - def finish(self, execution: PreSubmitExecutionResult) -> None: - """Retain the exact owner-issued result object for final persistence.""" - if not self._started or self._execution is not None: - raise PreSubmitEvidenceConflict("pre_submit_attempt_claim_invalid") - self._execution = execution - - async def validate_completion( - self, - session: AsyncSession, - request: PreSubmitEvidencePersistenceRequest, - ) -> None: - """A UUID or copied result cannot stand in for the winning invocation.""" - if not self._started or self._execution is not request.execution: - raise PreSubmitEvidenceConflict("pre_submit_attempt_execution_invalid") - await self._reserved(session, lock=True) - - async def complete(self, session: AsyncSession, evidence_id: UUID) -> None: - """Link evidence atomically; deferred database guards verify both directions.""" - result = await session.execute( - update(PreSubmitExecutionAttempt) - .where( - PreSubmitExecutionAttempt.id == str(self.attempt_id), - PreSubmitExecutionAttempt.claim_nonce == str(self._nonce), - PreSubmitExecutionAttempt.status == "reserved", - ) - .values(status="completed", evidence_set_id=str(evidence_id)) - ) - if result.rowcount != 1: - raise PreSubmitEvidenceConflict("pre_submit_attempt_completion_conflict") - - -class PreSubmitAttemptStore: - """Own reservation issuance and verified reads of canonical ART results.""" - - def __init__(self, session: AsyncSession) -> None: - """Bind issuance to one transaction-owning ART request.""" - self._session = session - self._pending: _Reservation | None = None - - async def reserve( - self, - *, - context: PreSubmitEvidenceContext, - plan: EffectivePreSubmissionExecutionPlan, - packet: SubmissionPacketView, - idempotency_key: UUID, - request: PreparedBundleMaterializationRequest, - ) -> _Reservation | PreSubmitExecutionAttempt: - """Reserve after authorization; an existing unresolved attempt cannot execute.""" - if not self._session.in_transaction() or self._session.in_nested_transaction(): - raise RuntimeError("pre-submit reservation requires one root transaction") - body = logical_request(context, plan, packet) - digest = canonical_json_hash(body) - attempt_id = new_record_id() - nonce = uuid4() - inserted = await self._session.scalar( - insert(PreSubmitExecutionAttempt) - .values( - id=str(attempt_id), - idempotency_key=str(idempotency_key), - actor_profile_id=str(context.actor_profile_id), - identity_link_id=str(context.identity_link_id), - task_id=str(context.task_id), - assignment_id=str(context.assignment_id), - prepared_generation_id=str(context.prepared_generation_id), - claim_nonce=str(nonce), - request_json=body, - request_digest=digest, - status="reserved", - ) - .on_conflict_do_nothing(index_elements=["actor_profile_id", "idempotency_key"]) - .returning(PreSubmitExecutionAttempt.id) - ) - if inserted is not None: - self._pending = _Reservation( - attempt_id, nonce, digest, replace(request, prepared_authorization=None) - ) - return self._pending - row = await self._session.scalar( - select(PreSubmitExecutionAttempt) - .where( - PreSubmitExecutionAttempt.actor_profile_id == str(context.actor_profile_id), - PreSubmitExecutionAttempt.idempotency_key == str(idempotency_key), - ) - .execution_options(populate_existing=True) - ) - if row is None or row.request_digest != digest or row.request_json != body: - raise PreSubmitEvidenceConflict("pre_submit_attempt_request_conflict") - if row.status != "completed": - raise PreSubmitEvidenceConflict("pre_submit_attempt_outcome_unresolved") - return row - - async def committed_claim(self, reservation: object) -> PreSubmitAttemptClaim: - """Mint only after a successful commit, with a new transaction visibility check.""" - if ( - self._session.in_transaction() - or type(reservation) is not _Reservation - or reservation is not self._pending - ): - raise PreSubmitEvidenceConflict("pre_submit_attempt_not_committed") - self._pending = None - async with self._session.begin(): - row = await self._session.get( - PreSubmitExecutionAttempt, str(reservation.attempt_id), populate_existing=True - ) - if ( - row is None - or row.claim_nonce != str(reservation.claim_nonce) - or row.request_digest != reservation.request_digest - or row.status != "reserved" - ): - raise PreSubmitEvidenceConflict("pre_submit_attempt_not_committed") - claim = object.__new__(PreSubmitAttemptClaim) - claim.attempt_id = reservation.attempt_id - claim.request_digest = reservation.request_digest - claim._nonce = reservation.claim_nonce - claim._request = reservation.request - claim._started = False - claim._execution = None - claim._session = self._session - return claim - - async def read_completed( - self, - *, - row: PreSubmitExecutionAttempt, - context: PreSubmitEvidenceContext, - plan: EffectivePreSubmissionExecutionPlan, - ) -> PreSubmitEvidencePersistenceResult: - """Return original evidence after caller revalidates original fixed-service facts.""" - evidence = await self._session.get( - PreSubmitEvidenceSet, row.evidence_set_id, populate_existing=True - ) - if ( - evidence is None - or row.status != "completed" - or evidence.attempt_id != row.id - or evidence.attempt_request_digest != row.request_digest - or evidence.packet_sha256 is None - or evidence.packet_sha256 != row.request_json.get("packet_sha256") - ): - raise PreSubmitEvidenceConflict("pre_submit_attempt_evidence_invalid") - try: - SubmissionManifest.from_dict( - evidence.semantic_manifest_body, sha256=evidence.semantic_manifest_sha256, - ) - except (TypeError, ValueError) as exc: - raise PreSubmitEvidenceConflict("pre_submit_attempt_manifest_invalid") from exc - rows = tuple( - ( - await self._session.scalars( - select(PreSubmitEvidenceResult) - .where( - PreSubmitEvidenceResult.evidence_set_id == evidence.id, - ) - .order_by(PreSubmitEvidenceResult.result_order) - ) - ).all() - ) - if len(rows) != len(plan.entries) or any( - r.metadata_json is None or r.checker_order is None for r in rows - ): - raise PreSubmitEvidenceConflict("pre_submit_attempt_result_unavailable") - execution = PreSubmitExecutionResult( - custody=PreSubmitExecutionCustody( - prepared_generation_id=UUID(row.prepared_generation_id), - archive_sha256=evidence.archive_sha256, - archive_byte_count=evidence.archive_byte_count, - semantic_manifest_sha256=evidence.semantic_manifest_sha256, - storage_scheme=evidence.storage_scheme, - ), - checker_facts=PreSubmissionExecutionFacts( - plan_sha256=evidence.effective_plan_sha256, - eligible=evidence.eligible, - entries=tuple( - PreSubmissionExecutionEntryFacts( - dispatch_authority=r.dispatch_authority, - definition_id=r.definition_id, - definition_version=r.definition_version, - public_name=r.public_name, - policy_source=r.source, - effective_plan_sha256=r.effective_plan_sha256, - rule_instance_id=r.rule_instance_id, - locked_policy_sha256=r.locked_policy_sha256, - phase=r.phase, - order=r.checker_order, - classification=r.classification, - severity=r.severity, - checker_execution_status=r.status, - failure_code=r.failure_code, - message_code=r.message_code, - metadata=tuple(tuple(item) for item in r.metadata_json), - ) - for r in rows - ), - ), - ) - _validate_execution(plan, execution) - original = replace(context, prepared_generation_id=UUID(row.prepared_generation_id)) - identity = original.operation_identity( - effective_plan_sha256=plan.plan_sha256, - attempt_id=UUID(row.id), - attempt_request_digest=row.request_digest, - ) - expected = _PreSubmitEvidenceRepository._set_values(original, plan, execution, identity) - if any(getattr(evidence, name) != value for name, value in expected.items()): - raise PreSubmitEvidenceConflict("pre_submit_attempt_result_digest_invalid") - for ordinal, (member, entry) in enumerate(zip(rows, execution.entries, strict=True)): - values = _PreSubmitEvidenceRepository._result_values( - ordinal, entry, plan.entries[ordinal].result_schema - ) - if any(getattr(member, name) != value for name, value in values.items()): - raise PreSubmitEvidenceConflict("pre_submit_attempt_member_invalid") - reference = PersistedPreSubmitEvidence(UUID(evidence.id), identity, True) - failure_audit = ( - None - if execution.eligible - else pre_submit_failure_audit_payload( - actor_profile_id=original.actor_profile_id, - project_id=original.project_id, - task_id=original.task_id, - prepared_generation_id=original.prepared_generation_id, - evidence=reference, - execution=execution, - catalogue_id=plan.catalogue_id, - catalogue_version=plan.catalogue_version, - ) - ) - return PreSubmitEvidencePersistenceResult(reference, None, failure_audit, execution) diff --git a/backend/app/modules/artifacts/pre_submit_evidence.py b/backend/app/modules/artifacts/pre_submit_evidence.py deleted file mode 100644 index ac235a0ee..000000000 --- a/backend/app/modules/artifacts/pre_submit_evidence.py +++ /dev/null @@ -1,877 +0,0 @@ -"""Immutable persistence for one exact effective pre-submit execution.""" - -from __future__ import annotations - -from dataclasses import asdict, dataclass, field -from typing import TYPE_CHECKING -import threading -from uuid import NAMESPACE_URL, UUID, uuid5 -from app.core.identifiers import new_record_id - -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.hashing import canonical_json_hash -from app.modules.artifacts.models import PreSubmitEvidenceResult, PreSubmitEvidenceSet -from app.modules.artifacts.sources import ArtifactCommitment -from app.modules.artifacts.submission_manifest import SubmissionManifest -from app.modules.checkers.api import ( - ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, - EffectivePreSubmissionExecutionPlan, - PreSubmissionExecutionEntryFacts, - PreSubmissionExecutionFacts, - PreSubmissionInfrastructureUnavailableError, - validate_pre_submission_execution_facts, -) -from app.modules.projects.api import ( - ProjectLockedPolicyContextPort, - ProjectLockedPolicyContextRequest, - ProjectLockedPolicyContextUnavailable, -) -from app.modules.tasks.api import ( - TaskSubmissionContextFacts, - TaskSubmissionContextPort, - TaskSubmissionContextRequest, - TaskSubmissionContextUnavailable, -) - -if TYPE_CHECKING: - from app.modules.artifacts.pre_submit_attempts import PreSubmitAttemptClaim - - -def validate_predecessor_lineage( - task_context: TaskSubmissionContextFacts, - *, - predecessor_submission_id: UUID | None, - predecessor_submission_version: int | None, - checker_remediation_manifest_id: UUID | None, -) -> None: - """Require the post-byte predecessor to equal the initially checked version.""" - predecessor = task_context.predecessor - if ( - (predecessor is None) != (predecessor_submission_id is None) - or predecessor is not None - and ( - predecessor.submission_id != predecessor_submission_id - or predecessor.version != predecessor_submission_version - ) - or task_context.checker_remediation_manifest_id != checker_remediation_manifest_id - ): - raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") - - -@dataclass(frozen=True, slots=True) -class PreSubmitExecutionCustody: - """ART-owned custody observed around one CHECKER execution.""" - - prepared_generation_id: UUID - archive_sha256: str - archive_byte_count: int - semantic_manifest_sha256: str - storage_scheme: str - - -@dataclass(frozen=True, slots=True) -class PreSubmitExecutionResult: - """ART custody combined with dependency-safe CHECKER result facts.""" - - custody: PreSubmitExecutionCustody - checker_facts: PreSubmissionExecutionFacts - - @property - def plan_sha256(self) -> str: - return self.checker_facts.plan_sha256 - - @property - def eligible(self) -> bool: - return self.checker_facts.eligible - - @property - def entries(self) -> tuple[PreSubmissionExecutionEntryFacts, ...]: - return self.checker_facts.entries - - -class PreSubmitEvidenceConflict(RuntimeError): - """Reject reuse of one operation identity with different server-owned facts.""" - - -@dataclass(frozen=True, slots=True) -class PreSubmitEvidenceContext: - """Exact locked facts revalidated by the transaction-owning caller.""" - - actor_profile_id: UUID - identity_link_id: UUID - project_id: UUID - task_id: UUID - assignment_id: UUID - predecessor_submission_id: UUID | None - predecessor_submission_version: int | None - checker_remediation_manifest_id: UUID | None = field(default=None, kw_only=True) - prepared_generation_id: UUID - archive_sha256: str - archive_byte_count: int - semantic_manifest_id: UUID - semantic_manifest_sha256: str - guide_id: UUID - guide_version: str - source_snapshot_id: UUID - source_snapshot_sha256: str - locked_guide_sha256: str - effective_policy_id: UUID - locked_artifact_policy_sha256: str - pre_submit_policy_id: UUID - locked_checker_policy_sha256: str - catalogue_id: str - catalogue_version: str - catalogue_manifest_sha256: str - storage_scheme: str - - def __post_init__(self) -> None: - identifiers = ( - self.actor_profile_id, - self.identity_link_id, - self.project_id, - self.task_id, - self.assignment_id, - self.prepared_generation_id, - self.semantic_manifest_id, - self.guide_id, - self.source_snapshot_id, - self.effective_policy_id, - self.pre_submit_policy_id, - ) - if any(type(value) is not UUID for value in identifiers) or ( - self.predecessor_submission_id is not None - and not isinstance(self.predecessor_submission_id, UUID) - ) or ( - self.checker_remediation_manifest_id is not None - and not isinstance(self.checker_remediation_manifest_id, UUID) - ): - raise ValueError("pre-submit evidence identity is invalid") - if type(self.archive_byte_count) is not int or self.archive_byte_count < 0: - raise ValueError("pre-submit evidence archive size is invalid") - if (self.predecessor_submission_id is None) != ( - self.predecessor_submission_version is None - ) or ( - self.predecessor_submission_version is not None - and ( - type(self.predecessor_submission_version) is not int - or self.predecessor_submission_version < 1 - ) - ): - raise ValueError("pre-submit evidence predecessor lineage is invalid") - if (self.predecessor_submission_id is None) != ( - self.checker_remediation_manifest_id is None - ): - raise ValueError("pre-submit evidence remediation source is invalid") - for digest in ( - self.archive_sha256, - self.semantic_manifest_sha256, - self.locked_guide_sha256, - self.source_snapshot_sha256, - self.locked_artifact_policy_sha256, - self.locked_checker_policy_sha256, - self.catalogue_manifest_sha256, - ): - ArtifactCommitment.validate_sha256(digest) - if any( - type(value) is not str or not value - for value in ( - self.guide_version, - self.catalogue_id, - self.catalogue_version, - self.storage_scheme, - ) - ): - raise ValueError("pre-submit evidence catalogue identity is invalid") - if self.storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES: - raise ValueError("pre-submit evidence storage scheme is invalid") - - def operation_identity( - self, *, effective_plan_sha256: str, attempt_id: UUID, attempt_request_digest: str - ) -> str: - """Derive the sole replay namespace from every custody and policy fact.""" - return canonical_json_hash( - { - "domain": "workstream.pre_submit_evidence_operation.v1", - **{ - key: str(value) if isinstance(value, UUID) else value - for key, value in asdict(self).items() - if key != "checker_remediation_manifest_id" or value is not None - }, - "effective_plan_sha256": effective_plan_sha256, - "attempt_id": str(attempt_id), - "attempt_request_digest": attempt_request_digest, - } - ) - - -@dataclass(frozen=True, slots=True) -class PersistedPreSubmitEvidence: - evidence_set_id: UUID - operation_identity: str - replayed: bool - - -@dataclass(frozen=True, slots=True) -class PreSubmitEvidenceInput: - """Exact custody and plan facts resolved before and after execution.""" - - actor_profile_id: UUID - identity_link_id: UUID - task_id: UUID - assignment_id: UUID - predecessor_submission_id: UUID | None - expected_predecessor_submission_version: int | None - expected_checker_remediation_manifest_id: UUID | None = field(default=None, kw_only=True) - prepared_generation_id: UUID - archive_sha256: str - archive_byte_count: int - semantic_manifest_sha256: str - plan: EffectivePreSubmissionExecutionPlan - - -@dataclass(frozen=True, slots=True) -class PreSubmitEvidencePersistenceRequest(PreSubmitEvidenceInput): - """Persist only the execution issued by the committed ART attempt claim.""" - - execution: PreSubmitExecutionResult - attempt: PreSubmitAttemptClaim - manifest: SubmissionManifest - - -@dataclass(frozen=True, slots=True) -class PreSubmitEvidencePersistenceResult: - """Durable evidence identity plus optional process-local pass capability.""" - - evidence: PersistedPreSubmitEvidence - pass_capability: PreSubmitPassCapability | None - failure_audit: dict[str, object] | None - execution: PreSubmitExecutionResult - - -def pre_submit_failure_audit_payload( - *, - actor_profile_id: UUID, - project_id: UUID, - task_id: UUID, - prepared_generation_id: UUID, - evidence: PersistedPreSubmitEvidence, - execution: PreSubmitExecutionResult, - catalogue_id: str, - catalogue_version: str, -) -> dict[str, object]: - """Return the sole bounded, path-free audit projection for a blocked attempt.""" - if execution.eligible: - raise ValueError("passing pre-submit execution has no failure audit projection") - counts = {"failed": 0, "warning": 0, "not_run": 0} - categories: set[str] = set() - failure_codes: set[str] = set() - result_outcomes: list[dict[str, str]] = [] - for result in execution.entries: - result_outcomes.append( - { - "definition_id": result.definition_id, - "definition_version": result.definition_version, - "status": result.checker_execution_status, - "message_code": result.message_code, - } - ) - if result.checker_execution_status == "failed": - counts["failed"] += 1 - categories.add(result.classification) - if result.failure_code is not None: - failure_codes.add(result.failure_code) - elif result.checker_execution_status == "warning": - counts["warning"] += 1 - elif result.checker_execution_status == "dependency_not_run": - counts["not_run"] += 1 - return { - "event_type": "pre_submission_check_failed", - "actor_profile_id": str(actor_profile_id), - "project_id": str(project_id), - "task_id": str(task_id), - "preparation_attempt_id": str(prepared_generation_id), - "pre_submit_evidence_set_id": str(evidence.evidence_set_id), - "effective_plan_sha256": execution.plan_sha256, - "terminal_status": "blocked", - "catalogue_id": catalogue_id, - "catalogue_version": catalogue_version, - "failed_count": counts["failed"], - "warning_count": counts["warning"], - "not_run_count": counts["not_run"], - "failure_categories": sorted(categories), - "failure_codes": sorted(failure_codes), - "result_outcomes": result_outcomes, - "outcome_code": "pre_submission_checker_failed", - } - - -def semantic_manifest_identity(semantic_manifest_sha256: str) -> UUID: - """Derive one provider-neutral identity from the canonical manifest digest.""" - ArtifactCommitment.validate_sha256(semantic_manifest_sha256) - return uuid5( - NAMESPACE_URL, - f"workstream:submission-semantic-manifest:v1:{semantic_manifest_sha256}", - ) - - -def _validate_execution( - plan: EffectivePreSubmissionExecutionPlan, - execution: PreSubmitExecutionResult, -) -> None: - """Reject custody or CHECKER facts not bound to the exact immutable plan.""" - custody = execution.custody - try: - ArtifactCommitment.validate_sha256(custody.archive_sha256) - ArtifactCommitment.validate_sha256(custody.semantic_manifest_sha256) - except ValueError as exc: - raise PreSubmitEvidenceConflict("pre_submission_result_context_invalid") from exc - if ( - type(custody.prepared_generation_id) is not UUID - or type(custody.archive_byte_count) is not int - or custody.archive_byte_count < 0 - or custody.storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES - or type(execution.eligible) is not bool - or execution.plan_sha256 != plan.plan_sha256 - or len(execution.entries) != len(plan.entries) - ): - raise PreSubmitEvidenceConflict("pre_submission_result_context_invalid") - try: - validate_pre_submission_execution_facts( - plan, - PreSubmissionExecutionFacts( - plan_sha256=execution.plan_sha256, - eligible=execution.eligible, - entries=execution.entries, - ), - ) - except PreSubmissionInfrastructureUnavailableError as exc: - raise PreSubmitEvidenceConflict( - "pre_submission_result_context_invalid" - ) from exc - - -_PRE_SUBMIT_PASS_CAPABILITY_SEAL = object() - - -class PreSubmitPassCapability: - """Single-use process-local proof for immediate 04C continuation only.""" - - __slots__ = ( - "_consumed", - "_binding", - "_lock", - "_owner", - "_seal", - "evidence_set_id", - "prepared_generation_id", - "predecessor_submission_id", - "checker_remediation_manifest_id", - "effective_plan_sha256", - "archive_sha256", - "semantic_manifest_sha256", - "storage_scheme", - ) - - def __init__(self, *_: object, **__: object) -> None: - """Reject direct construction outside the evidence service.""" - raise TypeError("PreSubmitPassCapability can only be created by pre-submit evidence") - - @classmethod - def _from_evidence_service( - cls, - *, - owner: PreSubmitEvidenceService, - binding: object, - evidence_set_id: UUID, - prepared_generation_id: UUID, - predecessor_submission_id: UUID | None, - checker_remediation_manifest_id: UUID | None, - effective_plan_sha256: str, - archive_sha256: str, - semantic_manifest_sha256: str, - storage_scheme: str, - ) -> PreSubmitPassCapability: - """Mint only from the service that persisted fresh passing evidence.""" - if type(owner) is not PreSubmitEvidenceService or not owner._claims_pass_binding(binding): - raise TypeError("PreSubmitPassCapability can only be created by pre-submit evidence") - capability = object.__new__(cls) - capability._owner = owner - capability._binding = binding - capability.evidence_set_id = evidence_set_id - capability.prepared_generation_id = prepared_generation_id - capability.predecessor_submission_id = predecessor_submission_id - capability.checker_remediation_manifest_id = checker_remediation_manifest_id - capability.effective_plan_sha256 = effective_plan_sha256 - capability.archive_sha256 = archive_sha256 - capability.semantic_manifest_sha256 = semantic_manifest_sha256 - capability.storage_scheme = storage_scheme - capability._consumed = False - capability._lock = threading.Lock() - capability._seal = _PRE_SUBMIT_PASS_CAPABILITY_SEAL - return capability - - def consume( - self, - *, - prepared_generation_id: UUID, - predecessor_submission_id: UUID | None, - checker_remediation_manifest_id: UUID | None, - effective_plan_sha256: str, - archive_sha256: str, - semantic_manifest_sha256: str, - storage_scheme: str, - ) -> UUID: - """Consume once only when the immediate continuation facts still match.""" - with self._lock: - if ( - self._seal is not _PRE_SUBMIT_PASS_CAPABILITY_SEAL - or self._consumed - or ( - prepared_generation_id != self.prepared_generation_id - or predecessor_submission_id != self.predecessor_submission_id - or checker_remediation_manifest_id != self.checker_remediation_manifest_id - or effective_plan_sha256 != self.effective_plan_sha256 - or archive_sha256 != self.archive_sha256 - or semantic_manifest_sha256 != self.semantic_manifest_sha256 - or storage_scheme != self.storage_scheme - ) - ): - raise PreSubmitEvidenceConflict("pre_submit_pass_capability_invalid") - if not self._owner._consume_pass_binding(self._binding): - raise PreSubmitEvidenceConflict("pre_submit_pass_capability_invalid") - self._consumed = True - return self.evidence_set_id - - def _assert_live_prepared_custody( - self, - *, - prepared_generation_id: UUID, - archive_sha256: str, - ) -> None: - """Fail before handoff when this capability is spent or for other bytes.""" - with self._lock: - if ( - self._seal is not _PRE_SUBMIT_PASS_CAPABILITY_SEAL - or self._consumed - or not self._owner._claims_pass_binding(self._binding) - or prepared_generation_id != self.prepared_generation_id - or archive_sha256 != self.archive_sha256 - ): - raise PreSubmitEvidenceConflict("pre_submit_pass_capability_invalid") - - -class _PreSubmitEvidenceRepository: - """Insert or replay one immutable evidence set in the caller transaction.""" - - def __init__( - self, - session: AsyncSession, - *, - task_contexts: TaskSubmissionContextPort, - project_contexts: ProjectLockedPolicyContextPort, - ) -> None: - self._session = session - self._task_contexts = task_contexts - self._project_contexts = project_contexts - - async def persist( - self, - *, - context: PreSubmitEvidenceContext, - plan: EffectivePreSubmissionExecutionPlan, - execution: PreSubmitExecutionResult, - attempt_id: UUID, - attempt_request_digest: str, - packet_sha256: str, - manifest: SubmissionManifest, - ) -> PersistedPreSubmitEvidence: - """Write the winning attempt's result once; the attempt owner handles replay.""" - transaction = self._session.sync_session.get_transaction() - if ( - transaction is None - or not transaction.is_active - or self._session.in_nested_transaction() - ): - raise RuntimeError("pre-submit evidence requires one root transaction") - _validate_execution(plan, execution) - operation_identity = context.operation_identity( - effective_plan_sha256=execution.plan_sha256, attempt_id=attempt_id, - attempt_request_digest=attempt_request_digest, - ) - values = self._set_values(context, plan, execution, operation_identity) - ArtifactCommitment.validate_sha256(packet_sha256) - values.update(attempt_id=str(attempt_id), attempt_request_digest=attempt_request_digest, - packet_sha256=packet_sha256, - semantic_manifest_body=SubmissionManifest.from_dict( - manifest.as_dict(), sha256=context.semantic_manifest_sha256, - ).as_dict()) - evidence_set_id = new_record_id() - await self._session.execute( - insert(PreSubmitEvidenceSet).values(id=str(evidence_set_id), **values) - ) - for result_order, (plan_entry, result) in enumerate( - zip(plan.entries, execution.entries, strict=True) - ): - self._session.add( - PreSubmitEvidenceResult( - id=str(new_record_id()), - evidence_set_id=str(evidence_set_id), - **self._result_values(result_order, result, plan_entry.result_schema), - ) - ) - await self._session.flush() - return PersistedPreSubmitEvidence( - evidence_set_id=evidence_set_id, - operation_identity=operation_identity, - replayed=False, - ) - - @staticmethod - def _result_values( - result_order: int, - result: PreSubmissionExecutionEntryFacts, - schema_version: str, - ) -> dict[str, object]: - return { - "result_order": result_order, - "checker_order": result.order, - "metadata_json": [list(item) for item in result.metadata], - "schema_version": schema_version, - "dispatch_authority": result.dispatch_authority, - "definition_id": result.definition_id, - "definition_version": result.definition_version, - "public_name": result.public_name, - "source": result.policy_source, - "phase": result.phase, - "classification": result.classification, - "severity": result.severity, - "status": result.checker_execution_status, - "failure_code": result.failure_code, - "message_code": result.message_code, - "effective_plan_sha256": result.effective_plan_sha256, - "rule_instance_id": result.rule_instance_id, - "locked_policy_sha256": result.locked_policy_sha256, - } - - @staticmethod - def _set_values( - context: PreSubmitEvidenceContext, - plan: EffectivePreSubmissionExecutionPlan, - execution: PreSubmitExecutionResult, - operation_identity: str, - ) -> dict[str, object]: - values = { - key: str(value) if isinstance(value, UUID) else value - for key, value in asdict(context).items() - } - values["checker_remediation_manifest_id"] = context.checker_remediation_manifest_id - values = { - "operation_identity": operation_identity, - **values, - "effective_plan_sha256": execution.plan_sha256, - "terminal_status": "passed" if execution.eligible else "blocked", - "eligible": execution.eligible, - "result_count": len(execution.entries), - "result_manifest_sha256": canonical_json_hash( - { - "schema_version": "pre_submit_evidence_result_manifest.v1", - "entries": [ - { - "schema_version": plan_entry.result_schema, - "dispatch_authority": result.dispatch_authority, - "definition_id": result.definition_id, - "definition_version": result.definition_version, - "public_name": result.public_name, - "policy_source": result.policy_source, - "effective_plan_sha256": result.effective_plan_sha256, - "rule_instance_id": result.rule_instance_id, - "locked_policy_sha256": result.locked_policy_sha256, - "phase": result.phase, - "order": result.order, - "classification": result.classification, - "severity": result.severity, - "status": result.checker_execution_status, - "failure_code": result.failure_code, - "message_code": result.message_code, - "metadata": [list(item) for item in result.metadata], - } - for plan_entry, result in zip( - plan.entries, execution.entries, strict=True - ) - ], - } - ), - } - values["locked_policy_context_hash"] = canonical_json_hash( - { - "guide_id": values["guide_id"], - "guide_version": values["guide_version"], - "source_snapshot_id": values["source_snapshot_id"], - "source_snapshot_sha256": values["source_snapshot_sha256"], - "locked_guide_sha256": values["locked_guide_sha256"], - "effective_policy_id": values["effective_policy_id"], - "locked_artifact_policy_sha256": values["locked_artifact_policy_sha256"], - "pre_submit_policy_id": values["pre_submit_policy_id"], - "locked_checker_policy_sha256": values["locked_checker_policy_sha256"], - "effective_plan_sha256": values["effective_plan_sha256"], - } - ) - return values - - -class PreSubmitEvidenceService: - """Revalidate locked state and persist evidence in one caller transaction.""" - - def __init__( - self, - session: AsyncSession, - *, - task_contexts: TaskSubmissionContextPort, - project_contexts: ProjectLockedPolicyContextPort, - ) -> None: - self._session = session - self._task_contexts = task_contexts - self._project_contexts = project_contexts - self._repository = _PreSubmitEvidenceRepository( - session, - task_contexts=task_contexts, - project_contexts=project_contexts, - ) - self._live_pass_bindings: set[object] = set() - - def _claims_pass_binding(self, binding: object) -> bool: - """Recognize only an issuance registered by this live service instance.""" - return binding in getattr(self, "_live_pass_bindings", set()) - - def _consume_pass_binding(self, binding: object) -> bool: - """Retire one process-local issuance binding exactly once.""" - if binding not in self._live_pass_bindings: - return False - self._live_pass_bindings.remove(binding) - return True - - def _mint_pass_capability( - self, - *, - evidence_set_id: UUID, - prepared_generation_id: UUID, - predecessor_submission_id: UUID | None, - checker_remediation_manifest_id: UUID | None, - effective_plan_sha256: str, - archive_sha256: str, - semantic_manifest_sha256: str, - storage_scheme: str, - ) -> PreSubmitPassCapability: - """Register one unguessable issuance after fresh passing persistence.""" - binding = object() - self._live_pass_bindings.add(binding) - try: - return PreSubmitPassCapability._from_evidence_service( - owner=self, - binding=binding, - evidence_set_id=evidence_set_id, - prepared_generation_id=prepared_generation_id, - predecessor_submission_id=predecessor_submission_id, - checker_remediation_manifest_id=checker_remediation_manifest_id, - effective_plan_sha256=effective_plan_sha256, - archive_sha256=archive_sha256, - semantic_manifest_sha256=semantic_manifest_sha256, - storage_scheme=storage_scheme, - ) - except BaseException: - self._live_pass_bindings.discard(binding) - raise - - async def persist( - self, request: PreSubmitEvidencePersistenceRequest - ) -> PreSubmitEvidencePersistenceResult: - """Persist only when the post-execution database lineage remains exact.""" - transaction = self._session.sync_session.get_transaction() - if ( - transaction is None - or not transaction.is_active - or self._session.in_nested_transaction() - ): - raise RuntimeError("pre-submit evidence requires one root transaction") - _validate_execution(request.plan, request.execution) - custody = request.execution.custody - if type(request.manifest) is not SubmissionManifest: - raise PreSubmitEvidenceConflict("pre_submit_manifest_invalid") - try: - SubmissionManifest.from_dict( - request.manifest.as_dict(), sha256=request.semantic_manifest_sha256, - ) - except (TypeError, ValueError) as exc: - raise PreSubmitEvidenceConflict("pre_submit_manifest_invalid") from exc - if ( - request.prepared_generation_id != custody.prepared_generation_id - or request.archive_sha256 != custody.archive_sha256 - or request.archive_byte_count != custody.archive_byte_count - or request.semantic_manifest_sha256 != custody.semantic_manifest_sha256 - ): - raise PreSubmitEvidenceConflict("pre_submit_execution_custody_changed") - context = await self.lock_context(request, storage_scheme=custody.storage_scheme) - from app.modules.artifacts.pre_submit_attempts import PreSubmitAttemptClaim - - if type(request.attempt) is not PreSubmitAttemptClaim: - raise PreSubmitEvidenceConflict("pre_submit_attempt_claim_invalid") - await request.attempt.validate_completion(self._session, request) - evidence = await self._repository.persist( - context=context, - plan=request.plan, - execution=request.execution, - attempt_id=request.attempt.attempt_id, - attempt_request_digest=request.attempt.request_digest, - packet_sha256=request.attempt.packet_sha256, - manifest=request.manifest, - ) - await request.attempt.complete(self._session, evidence.evidence_set_id) - pass_capability = ( - self._mint_pass_capability( - evidence_set_id=evidence.evidence_set_id, - prepared_generation_id=request.prepared_generation_id, - predecessor_submission_id=request.predecessor_submission_id, - checker_remediation_manifest_id=request.expected_checker_remediation_manifest_id, - effective_plan_sha256=request.plan.plan_sha256, - archive_sha256=custody.archive_sha256, - semantic_manifest_sha256=custody.semantic_manifest_sha256, - storage_scheme=custody.storage_scheme, - ) - if request.execution.eligible - else None - ) - failure_audit = ( - None - if request.execution.eligible - else pre_submit_failure_audit_payload( - actor_profile_id=request.actor_profile_id, - project_id=context.project_id, - task_id=request.task_id, - prepared_generation_id=request.prepared_generation_id, - evidence=evidence, - execution=request.execution, - catalogue_id=request.plan.catalogue_id, - catalogue_version=request.plan.catalogue_version, - ) - ) - return PreSubmitEvidencePersistenceResult( - evidence=evidence, - pass_capability=pass_capability, - failure_audit=failure_audit, - execution=request.execution, - ) - - async def lock_context( - self, request: PreSubmitEvidenceInput, *, storage_scheme: str - ) -> PreSubmitEvidenceContext: - """Relock exact lineage when authority locks are already held by the workflow.""" - task_context = await self.lock_task_context(request) - return await self.lock_project_context( - request, task_context=task_context, storage_scheme=storage_scheme, - ) - - async def lock_task_context( - self, request: PreSubmitEvidenceInput, - ) -> TaskSubmissionContextFacts: - """Acquire TASK custody before the workflow locks contributor identity.""" - try: - task_context = await self._task_contexts.lock_submission_context( - TaskSubmissionContextRequest( - task_id=request.task_id, - assignment_id=request.assignment_id, - contributor_id=request.actor_profile_id, - predecessor_submission_id=request.predecessor_submission_id, - ) - ) - except TaskSubmissionContextUnavailable as exc: - raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") from exc - return task_context - - async def lock_project_context( - self, request: PreSubmitEvidenceInput, *, - task_context: TaskSubmissionContextFacts, storage_scheme: str, - ) -> PreSubmitEvidenceContext: - """Lock PROJECT and validate lineage after TASK and actor identity are held.""" - try: - references = task_context.locked_project_context - project_context = await self._project_contexts.lock_locked_policy_context( - ProjectLockedPolicyContextRequest( - project_id=references.project_id, - guide_version=references.guide_version, - source_snapshot_id=references.source_snapshot_id, - source_snapshot_hash=references.source_snapshot_hash, - effective_policy_id=references.effective_policy_id, - effective_policy_hash=references.effective_policy_hash, - pre_submit_policy_id=references.pre_submit_policy_id, - pre_submit_policy_bundle_hash=references.pre_submit_policy_bundle_hash, - ) - ) - except ProjectLockedPolicyContextUnavailable as exc: - raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") from exc - validate_predecessor_lineage( - task_context, - predecessor_submission_id=request.predecessor_submission_id, - predecessor_submission_version=( - request.expected_predecessor_submission_version - ), - checker_remediation_manifest_id=request.expected_checker_remediation_manifest_id, - ) - lineage = request.plan.lineage - if ( - task_context.contributor_id != request.actor_profile_id - or project_context.project_id != lineage.project_id - or project_context.guide_id != lineage.guide_id - or project_context.guide_version != lineage.guide_version - or project_context.source_snapshot_id != lineage.source_snapshot_id - or project_context.source_snapshot_hash != lineage.source_snapshot_hash - or project_context.effective_policy_id != lineage.effective_policy_id - or project_context.effective_policy_hash != lineage.effective_policy_hash - or project_context.pre_submit_policy_id != lineage.pre_submit_policy_id - or project_context.pre_submit_policy_bundle_hash - != lineage.pre_submit_policy_bundle_hash - ): - raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") - locked_guide_sha256 = canonical_json_hash( - { - "domain": "workstream.locked_task_guide.v1", - "project_id": str(project_context.project_id), - "guide_id": str(project_context.guide_id), - "guide_version": project_context.guide_version, - "source_snapshot_id": str(project_context.source_snapshot_id), - "source_snapshot_sha256": project_context.source_snapshot_hash, - } - ) - return PreSubmitEvidenceContext( - actor_profile_id=request.actor_profile_id, - identity_link_id=request.identity_link_id, - project_id=project_context.project_id, - task_id=request.task_id, - assignment_id=request.assignment_id, - predecessor_submission_id=request.predecessor_submission_id, - predecessor_submission_version=( - task_context.predecessor.version - if task_context.predecessor is not None - else None - ), - checker_remediation_manifest_id=task_context.checker_remediation_manifest_id, - prepared_generation_id=request.prepared_generation_id, - archive_sha256=request.archive_sha256, - archive_byte_count=request.archive_byte_count, - semantic_manifest_id=semantic_manifest_identity(request.semantic_manifest_sha256), - semantic_manifest_sha256=request.semantic_manifest_sha256, - guide_id=project_context.guide_id, - guide_version=project_context.guide_version, - source_snapshot_id=project_context.source_snapshot_id, - source_snapshot_sha256=project_context.source_snapshot_hash, - locked_guide_sha256=locked_guide_sha256, - effective_policy_id=project_context.effective_policy_id, - locked_artifact_policy_sha256=project_context.effective_policy_hash, - pre_submit_policy_id=project_context.pre_submit_policy_id, - locked_checker_policy_sha256=project_context.pre_submit_policy_bundle_hash, - catalogue_id=request.plan.catalogue_id, - catalogue_version=request.plan.catalogue_version, - catalogue_manifest_sha256=request.plan.catalogue_manifest_sha256, - storage_scheme=storage_scheme, - ) diff --git a/backend/app/modules/artifacts/preparation.py b/backend/app/modules/artifacts/preparation.py index af8f00a33..c7ef816b1 100644 --- a/backend/app/modules/artifacts/preparation.py +++ b/backend/app/modules/artifacts/preparation.py @@ -23,8 +23,8 @@ from app.interfaces.artifacts import ( ArtifactInputMismatchError, - ArtifactIntegrityError, ArtifactLimitExceededError, + ArtifactScratchIntegrityError, ArtifactStoreUnavailableError, ) from app.core.cancellation import ( @@ -80,12 +80,6 @@ class ArtifactPreparationDeadlineError(ArtifactStoreUnavailableError): code = "artifact_preparation_deadline_exceeded" -class ArtifactScratchIntegrityError(ArtifactIntegrityError): - """Raised when private scratch state violates its filesystem contract.""" - - code = "artifact_scratch_integrity_failure" - - @dataclass(frozen=True, slots=True) class ExternalMaterialFile: """One server-inspected file identity published to an external checker.""" @@ -110,6 +104,31 @@ def __post_init__(self) -> None: raise ValueError("external material file identity is invalid") +def _validate_external_material_tree( + directories: tuple[str, ...], files: tuple[ExternalMaterialFile, ...] +) -> None: + """Validate the shared closed path inventory without fabricating phase facts.""" + if ( + type(files) is not tuple + or type(directories) is not tuple + or any(type(item) is not ExternalMaterialFile for item in files) + or any( + type(path) is not str + or not path + or path.startswith("/") + or any(part in {"", ".", ".."} for part in path.split("/")) + or "\x00" in path + for path in directories + ) + or tuple(sorted(directories)) != directories + or len(set(directories)) != len(directories) + or tuple(sorted(files, key=lambda item: item.normalized_path)) != files + or len({item.normalized_path for item in files}) != len(files) + or set(directories) & {item.normalized_path for item in files} + ): + raise ValueError("external material tree is invalid") + + @dataclass(frozen=True, slots=True) class ExternalMaterialGrantRequest: """Exact pre-admission facts bound to one callback-scoped material grant.""" @@ -141,24 +160,125 @@ def __post_init__(self) -> None: ) or type(self.archive_byte_count) is not int or not 0 <= self.archive_byte_count <= HARD_MAXIMUM_ARTIFACT_BYTES - or type(self.files) is not tuple - or type(self.directories) is not tuple - or any(type(item) is not ExternalMaterialFile for item in self.files) + ): + raise ValueError("external material grant request is invalid") + _validate_external_material_tree(self.directories, self.files) + + def manifest_identity(self) -> dict[str, object]: + """Return the exact PRE lineage written into the private grant manifest.""" + return { + "phase": "pre_submit", + "prepared_generation_id": self.prepared_generation_id, + "attempt_id": self.attempt_id, + "attempt_request_digest": self.attempt_request_digest, + "archive_sha256": self.archive_sha256, + "archive_byte_count": self.archive_byte_count, + "semantic_manifest_sha256": self.semantic_manifest_sha256, + } + + +@dataclass(frozen=True, slots=True) +class PostExternalMaterialGrantRequest: + """Exact verified Submission, ART and lease facts for one POST callback.""" + + request_digest: str + submission_id: str + submission_version: int + evaluation_request_id: str + evaluation_request_digest: str + evaluation_generation: int + attempt_id: str + result_id: str + lease_id: str + lease_generation: int + lease_expires_at: str + binding_id: str + content_id: str + replica_id: str + content_sha256: str + byte_count: int + evidence_id: str + verification_receipt_id: str + verification_job_id: str + verification_generation: int + namespace_fingerprint: str + semantic_manifest_sha256: str + directories: tuple[str, ...] + files: tuple[ExternalMaterialFile, ...] + + def __post_init__(self) -> None: + """Reject a partial POST lineage before publishing any scratch path.""" + digests = ( + self.request_digest, + self.evaluation_request_digest, + self.content_sha256, + self.namespace_fingerprint, + self.semantic_manifest_sha256, + ) + identifiers = ( + self.submission_id, + self.evaluation_request_id, + self.attempt_id, + self.result_id, + self.lease_id, + self.binding_id, + self.content_id, + self.replica_id, + self.evidence_id, + self.verification_receipt_id, + self.verification_job_id, + ) + if ( + any(not re.fullmatch(r"sha256:[0-9a-f]{64}", value) for value in digests) or any( - type(path) is not str - or not path - or path.startswith("/") - or any(part in {"", ".", ".."} for part in path.split("/")) - or "\x00" in path - for path in self.directories + not re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + value, + ) + for value in identifiers ) - or tuple(sorted(self.directories)) != self.directories - or len(set(self.directories)) != len(self.directories) - or tuple(sorted(self.files, key=lambda item: item.normalized_path)) != self.files - or len({item.normalized_path for item in self.files}) != len(self.files) - or set(self.directories) & {item.normalized_path for item in self.files} + or any( + type(value) is not int or value <= 0 + for value in ( + self.submission_version, + self.evaluation_generation, + self.lease_generation, + self.verification_generation, + ) + ) + or type(self.byte_count) is not int + or not 0 <= self.byte_count <= HARD_MAXIMUM_ARTIFACT_BYTES + or not self.lease_expires_at.endswith(("Z", "+00:00")) ): - raise ValueError("external material grant request is invalid") + raise ValueError("external POST material grant request is invalid") + _validate_external_material_tree(self.directories, self.files) + + def manifest_identity(self) -> dict[str, object]: + """Return complete POST custody without storage-provider coordinates.""" + return { + "phase": "post_submit", + "submission_id": self.submission_id, + "submission_version": self.submission_version, + "evaluation_request_id": self.evaluation_request_id, + "evaluation_request_digest": self.evaluation_request_digest, + "evaluation_generation": self.evaluation_generation, + "attempt_id": self.attempt_id, + "result_id": self.result_id, + "lease_id": self.lease_id, + "lease_generation": self.lease_generation, + "lease_expires_at": self.lease_expires_at, + "binding_id": self.binding_id, + "content_id": self.content_id, + "replica_id": self.replica_id, + "content_sha256": self.content_sha256, + "byte_count": self.byte_count, + "evidence_id": self.evidence_id, + "verification_receipt_id": self.verification_receipt_id, + "verification_job_id": self.verification_job_id, + "verification_generation": self.verification_generation, + "namespace_fingerprint": self.namespace_fingerprint, + "semantic_manifest_sha256": self.semantic_manifest_sha256, + } @dataclass(frozen=True, slots=True) @@ -177,8 +297,8 @@ class ArtifactPreparationLimits: maximum_files: int = 8 maximum_concurrency: int = 4 minimum_free_bytes: int = HARD_MAXIMUM_ARTIFACT_BYTES - reservation_ttl_seconds: float = 2400.0 - total_deadline_seconds: float = 1800.0 + reservation_ttl_seconds: float = 3900.0 + total_deadline_seconds: float = 3400.0 cleanup_margin_seconds: float = 300.0 stream_buffer_bytes: int = 1024 * 1024 maximum_source_bytes: int = HARD_MAXIMUM_ARTIFACT_BYTES @@ -579,10 +699,13 @@ def extraction_workspace( def _external_material_grant( self, workspace: Path, - request: ExternalMaterialGrantRequest, + request: ExternalMaterialGrantRequest | PostExternalMaterialGrantRequest, ) -> Iterator[ExternalMaterialGrant]: """Publish one sealed request-bound workspace for the callback lifetime.""" - if type(request) is not ExternalMaterialGrantRequest: + if type(request) not in { + ExternalMaterialGrantRequest, + PostExternalMaterialGrantRequest, + }: raise ValueError("external material grant request is invalid") with self._tracked_operation(): grant = self._publish_external_grant_sync(workspace, request) @@ -594,7 +717,7 @@ def _external_material_grant( def _publish_external_grant_sync( self, workspace: Path, - request: ExternalMaterialGrantRequest, + request: ExternalMaterialGrantRequest | PostExternalMaterialGrantRequest, ) -> ExternalMaterialGrant: """Move the projected tree below a fixed mount and seal its exact manifest.""" try: @@ -684,12 +807,7 @@ def _publish_external_grant_sync( "protocol_version": _EXTERNAL_GRANT_PROTOCOL, "grant_id": grant_id, "request_digest": request.request_digest, - "prepared_generation_id": request.prepared_generation_id, - "attempt_id": request.attempt_id, - "attempt_request_digest": request.attempt_request_digest, - "archive_sha256": request.archive_sha256, - "archive_byte_count": request.archive_byte_count, - "semantic_manifest_sha256": request.semantic_manifest_sha256, + **request.manifest_identity(), "directories": list(observed_directories), "files": list(observed), } @@ -1049,7 +1167,7 @@ def _initialize_layout_locked(self) -> None: os.fsync(self._root_fd) def _validate_existing_root_marker(self) -> None: - """Require exact limits, with a safe-empty upgrade from the prior marker.""" + """Require the exact current scratch limits marker.""" descriptor = os.open( _ROOT_MARKER, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), @@ -1060,25 +1178,8 @@ def _validate_existing_root_marker(self) -> None: marker = os.read(descriptor, max(len(self._root_marker_content), 256) + 1) finally: os.close(descriptor) - if marker == self._root_marker_content: - return - if marker != self._legacy_marker_content(self._limits): + if marker != self._root_marker_content: raise ValueError("artifact scratch root marker is invalid") - ledger = self._read_ledger_optional() - if ledger is None or ledger["reservations"] or ledger["workspaces"]: - raise ValueError("artifact scratch root marker cannot migrate while in use") - descriptor = os.open( - _ROOT_MARKER, - os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), - dir_fd=self._root_fd, - ) - try: - self._assert_private_file_descriptor(descriptor, expected_mode=0o600) - self._write_all(descriptor, memoryview(self._root_marker_content)) - os.fsync(descriptor) - finally: - os.close(descriptor) - os.fsync(self._root_fd) @staticmethod def _marker_content(limits: ArtifactPreparationLimits) -> bytes: @@ -1103,27 +1204,6 @@ def _marker_content(limits: ArtifactPreparationLimits) -> bytes: ).encode("utf-8") return _ROOT_MARKER_PREFIX + hashlib.sha256(canonical).hexdigest().encode("ascii") + b"\n" - @staticmethod - def _legacy_marker_content(limits: ArtifactPreparationLimits) -> bytes: - """Reconstruct the pre-04B2 marker for one safe-empty local upgrade.""" - payload = { - "aggregate_reserved_bytes": limits.aggregate_reserved_bytes, - "cleanup_margin_seconds": float(limits.cleanup_margin_seconds), - "maximum_concurrency": limits.maximum_concurrency, - "maximum_files": limits.maximum_files, - "maximum_source_bytes": limits.maximum_source_bytes, - "minimum_free_bytes": limits.minimum_free_bytes, - "reservation_ttl_seconds": float(limits.reservation_ttl_seconds), - "stream_buffer_bytes": limits.stream_buffer_bytes, - "total_deadline_seconds": float(limits.total_deadline_seconds), - } - canonical = json.dumps( - payload, - allow_nan=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - return _ROOT_MARKER_PREFIX + hashlib.sha256(canonical).hexdigest().encode("ascii") + b"\n" @staticmethod def _validate_layout_entries( @@ -1522,22 +1602,6 @@ def _read_ledger_optional(self) -> dict[str, Any] | None: ledger = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ArtifactScratchIntegrityError("artifact scratch ledger is invalid") from exc - if isinstance(ledger, dict) and ledger.get("version") == 2: - if set(ledger) == {"version", "reservations"}: - ledger = {**ledger, "workspaces": []} - if set(ledger) == {"version", "reservations", "workspaces"}: - ledger = { - **ledger, - "version": _LEDGER_VERSION, - "workspaces": [ - { - **entry, - "reserved_bytes": 0, - "maximum_entries": self._limits.maximum_workspace_entries, - } - for entry in ledger["workspaces"] - ], - } self._validate_ledger(ledger) return ledger @@ -1990,7 +2054,7 @@ def external_material_grant( self, prepared: PreparedArtifact, workspace: Path, - request: ExternalMaterialGrantRequest, + request: ExternalMaterialGrantRequest | PostExternalMaterialGrantRequest, ) -> Iterator[ExternalMaterialGrant]: """Issue one grant from the live prepared source and current callback only.""" if type(prepared) is not PreparedArtifact or prepared._owner is not self: @@ -2002,9 +2066,20 @@ def external_material_grant( or not active.handle_issued or active.stream_claimed or self._external_grant_workspaces.get(binding) != workspace - or request.prepared_generation_id != str(prepared.generation_id) + ): + raise ArtifactScratchIntegrityError("external material provenance differs") + pre_mismatch = type(request) is ExternalMaterialGrantRequest and ( + request.prepared_generation_id != str(prepared.generation_id) or request.archive_sha256 != active.commitment.sha256 or request.archive_byte_count != active.commitment.byte_count + ) + post_mismatch = type(request) is PostExternalMaterialGrantRequest and ( + request.content_sha256 != active.commitment.sha256 + or request.byte_count != active.commitment.byte_count + ) + if ( + pre_mismatch + or post_mismatch ): raise ArtifactScratchIntegrityError("external material provenance differs") with self._manager._external_material_grant(workspace, request) as grant: diff --git a/backend/app/modules/artifacts/repository.py b/backend/app/modules/artifacts/repository.py index aaafc0b16..b5f0c9827 100644 --- a/backend/app/modules/artifacts/repository.py +++ b/backend/app/modules/artifacts/repository.py @@ -18,7 +18,6 @@ ArtifactBinding, ArtifactContent, ArtifactOperationReceipt, - PreSubmitEvidenceSet, ArtifactPutAttempt, ArtifactPutAttemptCharge, ArtifactPutObservationReceipt, @@ -295,27 +294,18 @@ async def get_put_attempt_by_operation( ) ) - async def lock_pre_submit_evidence_set( - self, evidence_set_id: str - ) -> PreSubmitEvidenceSet | None: - """Lock one immutable passing evidence identity for durable continuation.""" - return await self._session.scalar( - select(PreSubmitEvidenceSet) - .where(PreSubmitEvidenceSet.id == evidence_set_id) - .with_for_update(key_share=True) - .execution_options(populate_existing=True) - ) - async def get_submission_bundle_intent_by_evidence( - self, evidence_set_id: str + async def get_submission_bundle_intent_by_selected_run( + self, run_id: str ) -> SubmissionBundleDurableIntent | None: - """Load the one durable intent already created from an evidence set.""" + """Load the durable ART intent copied from one selected PRE terminal.""" return await self._session.scalar( select(SubmissionBundleDurableIntent).where( - SubmissionBundleDurableIntent.pre_submit_evidence_set_id == evidence_set_id + SubmissionBundleDurableIntent.selected_pre_submit_run_id == run_id ) ) + async def lock_submission_bundle_intent( self, intent_id: str ) -> SubmissionBundleDurableIntent | None: diff --git a/backend/app/modules/artifacts/schemas.py b/backend/app/modules/artifacts/schemas.py index c1beafb6f..c064f4fef 100644 --- a/backend/app/modules/artifacts/schemas.py +++ b/backend/app/modules/artifacts/schemas.py @@ -99,9 +99,26 @@ async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: class SubmissionBundleArtifactAdmissionRequest: """One passing prepared bundle selected for durable provider intent.""" - pre_submit_evidence_set_id: UUID + pre_submit_evidence_set_id: UUID | None custody: object replay_durable_intent_id: UUID | None + selected_pre_submit_run_id: UUID | None = None + selected_terminal_digest: str | None = None + + def __post_init__(self) -> None: + if ( + self.pre_submit_evidence_set_id is not None + or self.selected_pre_submit_run_id is None + or self.selected_terminal_digest is None + or ( + self.selected_terminal_digest is not None + and ( + len(self.selected_terminal_digest) != 71 + or not self.selected_terminal_digest.startswith("sha256:") + ) + ) + ): + raise ValueError("submission bundle admission lineage is invalid") @property def source(self) -> CommittedArtifactSource: @@ -191,7 +208,7 @@ class SubmissionBundleDurableIntentAuthorityFacts: predecessor_submission_id: UUID | None predecessor_submission_version: int | None checker_remediation_manifest_id: UUID | None = field(default=None, kw_only=True) - pre_submit_evidence_set_id: UUID + pre_submit_evidence_set_id: UUID | None prepared_generation_id: UUID guide_id: UUID guide_version: str @@ -204,12 +221,24 @@ class SubmissionBundleDurableIntentAuthorityFacts: effective_plan_sha256: str semantic_manifest_id: UUID semantic_manifest_sha256: str + semantic_manifest_body: dict[str, object] + packet_sha256: str archive_sha256: str archive_byte_count: int media_type: str storage_scheme: str operation_identity: str replay_durable_intent_id: UUID | None + selected_pre_submit_run_id: UUID | None = None + selected_terminal_digest: str | None = None + + def __post_init__(self) -> None: + if ( + self.pre_submit_evidence_set_id is not None + or self.selected_pre_submit_run_id is None + or self.selected_terminal_digest is None + ): + raise ValueError("submission bundle authority lineage is invalid") class ArtifactInternalResourceType(StrEnum): diff --git a/backend/app/modules/artifacts/selected_pre_submit_admission.py b/backend/app/modules/artifacts/selected_pre_submit_admission.py new file mode 100644 index 000000000..e6d7a631c --- /dev/null +++ b/backend/app/modules/artifacts/selected_pre_submit_admission.py @@ -0,0 +1,266 @@ +"""Bind selected PRE policy and default inputs to locked TASK/PROJECT custody.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from app.core.hashing import canonical_json_hash +from app.modules.artifacts.sources import ArtifactCommitment +from app.modules.artifacts.submission_manifest import ( + SubmissionCanonicalPredecessor, + semantic_manifest_identity, + SubmissionChangeGateResult, + SubmissionManifest, + bind_submission_change, +) +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + WorkstreamDefaultCheckerInput, + WorkstreamDefaultPredecessor, + workstream_default_input_body, +) +from app.modules.checkers.api.external_policy import ( + LockedExternalCheckerPolicies, + LockedExternalPostSubmitPolicy, + LockedExternalPreSubmitPolicy, +) +from app.modules.checkers.api.materialization import SubmissionMaterialEntry +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitAdmissionLineage, + SelectedPreSubmitReservationFacts, +) +from app.modules.projects.api import ProjectLockedPolicyContextFacts +from app.modules.tasks.api import TaskSubmissionContextFacts + + +def selected_pre_submit_attempt_request_digest( + *, + actor_profile_id: UUID, + identity_link_id: UUID, + task_id: UUID, + assignment_id: UUID, + predecessor_submission_id: UUID | None, + idempotency_key: UUID, + archive_sha256: str, + archive_byte_count: int, + packet_sha256: str, +) -> str: + """Bind the public command namespace to server-observed bytes and packet.""" + return canonical_json_hash( + { + "domain": "workstream.selected_pre_submit_attempt_request.v1", + "actor_profile_id": str(actor_profile_id), + "identity_link_id": str(identity_link_id), + "task_id": str(task_id), + "assignment_id": str(assignment_id), + "predecessor_submission_id": ( + str(predecessor_submission_id) + if predecessor_submission_id is not None + else None + ), + "idempotency_key": str(idempotency_key), + "archive_sha256": archive_sha256, + "archive_byte_count": archive_byte_count, + "packet_sha256": packet_sha256, + } + ) + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitAdmissionContext: + """Exact selected policy, default input and non-deciding change binding.""" + + policy: LockedExternalPreSubmitPolicy + post_policy: LockedExternalPostSubmitPolicy + policies_sha256: str + default_input: WorkstreamDefaultCheckerInput + change_gate: SubmissionChangeGateResult + rules_sha256: str + + +def build_selected_pre_submit_reservation( + *, + actor_profile_id: UUID, + identity_link_id: UUID, + task: TaskSubmissionContextFacts, + project: ProjectLockedPolicyContextFacts, + prepared_generation_id: UUID, + attempt_id: UUID, + idempotency_key: UUID, + attempt_request_digest: str, + commitment: ArtifactCommitment, + manifest: SubmissionManifest, + packet: SubmissionPacketView, + selected: SelectedPreSubmitAdmissionContext, + storage_scheme: str, + execution_budget_ms: int, +) -> SelectedPreSubmitReservationFacts: + """Bind all later ART-admission facts into the selected durable request.""" + if ( + task.contributor_id != actor_profile_id + or task.locked_project_context.project_id != project.project_id + or selected.policy.policy_sha256 + != canonical_json_hash( + selected.policy.model_dump(mode="json", exclude={"policy_sha256"}) + ) + ): + raise ValueError("selected PRE reservation context differs") + predecessor = task.predecessor + locked_guide_sha256 = canonical_json_hash( + { + "domain": "workstream.locked_task_guide.v1", + "project_id": str(project.project_id), + "guide_id": str(project.guide_id), + "guide_version": project.guide_version, + "source_snapshot_id": str(project.source_snapshot_id), + "source_snapshot_sha256": project.source_snapshot_hash, + } + ) + locked_policy_context_hash = canonical_json_hash( + { + "guide_id": str(project.guide_id), + "guide_version": project.guide_version, + "source_snapshot_id": str(project.source_snapshot_id), + "source_snapshot_sha256": project.source_snapshot_hash, + "locked_guide_sha256": locked_guide_sha256, + "effective_policy_id": str(project.effective_policy_id), + "locked_artifact_policy_sha256": project.effective_policy_hash, + "pre_submit_policy_id": str(project.pre_submit_policy_id), + "locked_checker_policy_sha256": project.pre_submit_policy_bundle_hash, + "effective_plan_sha256": selected.policy.policy_sha256, + } + ) + lineage = SelectedPreSubmitAdmissionLineage( + predecessor_submission_id=( + predecessor.submission_id if predecessor is not None else None + ), + predecessor_submission_version=( + predecessor.version if predecessor is not None else None + ), + checker_remediation_manifest_id=task.checker_remediation_manifest_id, + guide_id=project.guide_id, + guide_version=project.guide_version, + source_snapshot_id=project.source_snapshot_id, + source_snapshot_sha256=project.source_snapshot_hash, + locked_guide_sha256=locked_guide_sha256, + effective_policy_id=project.effective_policy_id, + effective_policy_sha256=project.effective_policy_hash, + pre_submit_policy_id=project.pre_submit_policy_id, + pre_submit_policy_sha256=project.pre_submit_policy_bundle_hash, + effective_plan_sha256=selected.policy.policy_sha256, + rules_sha256=selected.rules_sha256, + external_checker_policies_sha256=selected.policies_sha256, + post_submit_policy_sha256=selected.post_policy.policy_sha256, + post_submit_policy_body=selected.post_policy.model_dump(mode="json"), + locked_policy_context_hash=locked_policy_context_hash, + semantic_manifest_id=semantic_manifest_identity(manifest.sha256), + semantic_manifest_body=manifest.as_dict(), + packet_sha256=packet.sha256, + storage_scheme=storage_scheme, + ) + return SelectedPreSubmitReservationFacts( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + project_id=project.project_id, + task_id=task.task_id, + assignment_id=task.assignment_id, + attempt_id=attempt_id, + idempotency_key=idempotency_key, + attempt_request_digest=attempt_request_digest, + prepared_generation_id=prepared_generation_id, + policy_sha256=selected.policy.policy_sha256, + default_behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + default_input_sha256=canonical_json_hash( + workstream_default_input_body(selected.default_input) + ), + archive_sha256=commitment.sha256, + archive_byte_count=commitment.byte_count, + semantic_manifest_sha256=manifest.sha256, + execution_budget_ms=execution_budget_ms, + admission_lineage=lineage, + ) + + +def bind_selected_pre_submit_admission( + *, + task: TaskSubmissionContextFacts, + project: ProjectLockedPolicyContextFacts, + commitment: ArtifactCommitment, + manifest: SubmissionManifest, + predecessor: SubmissionCanonicalPredecessor | None, + predecessor_rules_sha256: str | None, + predecessor_exists: bool, + packet: SubmissionPacketView, +) -> SelectedPreSubmitAdmissionContext: + """Bind genuine locked policy and predecessor facts before CHECKER execution.""" + if project.project_id != task.locked_project_context.project_id: + raise ValueError("selected PRE task and project contexts differ") + policies = LockedExternalCheckerPolicies.model_validate_json( + project.external_checker_policies.value + ) + rules_sha256 = canonical_json_hash( + { + "schema_version": "workstream.task_checker_rules.v1", + "task_policy": task.locked_policy.model_dump(mode="json"), + "external_checker_policies_sha256": policies.policies_sha256, + } + ) + default_predecessor = ( + WorkstreamDefaultPredecessor( + submission_id=predecessor.submission_id, + submission_version=predecessor.submission_version, + archive_sha256=predecessor.archive_sha256, + semantic_manifest_sha256=predecessor.semantic_manifest_sha256, + ) + if predecessor is not None + else None + ) + if (predecessor_rules_sha256 is None) != (predecessor is None): + raise ValueError("selected PRE predecessor rules are incomplete") + change_gate = bind_submission_change( + commitment=commitment, + manifest=manifest, + predecessor=predecessor, + predecessor_exists=predecessor_exists, + current_predecessor=predecessor, + ) + entries = tuple( + SubmissionMaterialEntry( + normalized_path=item.normalized_path, + entry_type=item.entry_type.value, + byte_count=item.byte_count, + sha256=item.sha256, + executable=item.executable, + ) + for item in manifest.entries + ) + return SelectedPreSubmitAdmissionContext( + policy=policies.pre_submit, + post_policy=policies.post_submit, + policies_sha256=policies.policies_sha256, + default_input=WorkstreamDefaultCheckerInput( + archive_sha256=commitment.sha256, + archive_byte_count=commitment.byte_count, + archive_media_type=commitment.media_type, + semantic_manifest_sha256=manifest.sha256, + entries=entries, + predecessor=default_predecessor, + predecessor_exists=predecessor_exists, + current_predecessor=default_predecessor, + current_rules_sha256=rules_sha256, + predecessor_rules_sha256=predecessor_rules_sha256, + packet=packet, + ), + change_gate=change_gate, + rules_sha256=rules_sha256, + ) + + +__all__ = ( + "SelectedPreSubmitAdmissionContext", + "bind_selected_pre_submit_admission", + "build_selected_pre_submit_reservation", + "selected_pre_submit_attempt_request_digest", +) diff --git a/backend/app/modules/artifacts/selected_pre_submit_execution.py b/backend/app/modules/artifacts/selected_pre_submit_execution.py new file mode 100644 index 000000000..5157f31f1 --- /dev/null +++ b/backend/app/modules/artifacts/selected_pre_submit_execution.py @@ -0,0 +1,124 @@ +"""Selected PRE lease execution across short authority and owner transactions.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.artifacts.submission_materialization import ( + PreparedBundleMaterializationRequest, + PreparedBundleMaterializationService, +) +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitAuthorityPort, + SelectedPreSubmitCompletion, + SelectedPreSubmitCoordinationPort, + SelectedPreSubmitExecutionFacts, + SelectedPreSubmitFinalizeFacts, + SelectedPreSubmitInfrastructureUnavailable, + SelectedPreSubmitReservationFacts, +) + + +class SelectedPreSubmitExecutionService: + """Claim, execute and finalize one selected run without holding a DB root.""" + + def __init__( + self, + *, + session: AsyncSession, + coordinator: SelectedPreSubmitCoordinationPort, + authority: SelectedPreSubmitAuthorityPort, + materialization: PreparedBundleMaterializationService, + ) -> None: + self._session = session + self._coordinator = coordinator + self._authority = authority + self._materialization = materialization + + async def execute( + self, + *, + reservation: SelectedPreSubmitReservationFacts, + materialization: PreparedBundleMaterializationRequest, + ) -> SelectedPreSubmitCompletion: + """Use short claim/finalize roots around one process-local ART execution.""" + run_id = reservation.attempt_id + async with self._session.begin(): + # Preparing the fixed principal precedes CHECKERS' TASK/run locks. + async with self._authority.prepare_execution(reservation) as prepared: + lease = await self._coordinator.claim( + run_id=run_id, + facts=reservation, + authority=prepared, + ) + + execution: SelectedPreSubmitExecutionFacts | None = None + infrastructure_failure_code: str | None = None + try: + capability = await self._coordinator.committed_execution( + run_id=run_id, lease=lease + ) + execution = await self._materialization.materialize_selected_pre_submit( + materialization, + run_id=run_id, + lease=lease, + execution_authority=capability, + ) + except SelectedPreSubmitInfrastructureUnavailable as exc: + infrastructure_failure_code = exc.code + + finalize_facts = SelectedPreSubmitFinalizeFacts( + lease=lease, + execution=execution, + infrastructure_failure_code=infrastructure_failure_code, + ) + async with self._session.begin(): + async with self._authority.prepare_finalization( + finalize_facts + ) as prepared: + return await self._coordinator.finalize( + run_id=run_id, + lease=lease, + authority=prepared, + execution=execution, + infrastructure_failure_code=infrastructure_failure_code, + ) + + async def recover_expired( + self, + *, + run_id: UUID, + reservation: SelectedPreSubmitReservationFacts, + ) -> SelectedPreSubmitCompletion: + """Finalize a crashed lease without recreating its process-local grant.""" + if run_id != reservation.attempt_id: + raise SelectedPreSubmitInfrastructureUnavailable( + "material_unavailable" + ) + async with self._session.begin(): + lease = await self._coordinator.read_expired_lease( + run_id=reservation.attempt_id, + facts=reservation, + ) + + finalize_facts = SelectedPreSubmitFinalizeFacts( + lease=lease, + execution=None, + infrastructure_failure_code="deadline_exceeded", + ) + async with self._session.begin(): + async with self._authority.prepare_finalization( + finalize_facts + ) as prepared: + return await self._coordinator.finalize( + run_id=reservation.attempt_id, + lease=lease, + authority=prepared, + execution=None, + infrastructure_failure_code="deadline_exceeded", + ) + + +__all__ = ("SelectedPreSubmitExecutionService",) diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index 8de37e41d..c4fed2406 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -63,7 +63,6 @@ ArtifactStorageNamespace, ArtifactVerificationJob, ArtifactVerificationReceipt, - PreSubmitEvidenceSet, SubmissionBundleDurableIntent, ) from app.modules.artifacts.metrics import ( @@ -198,6 +197,7 @@ class _AdmissionFacts: pre_submit_evidence_set_id: str | None operation_identity: str task_import_source_id: str | None = None + selected_pre_submit_run_id: str | None = None def artifact_storage_namespace_spec( @@ -2039,19 +2039,6 @@ async def admit( authority_facts, operation_identity=replay_attempt.operation_identity, ) - consumed_evidence_id = request.custody.pass_capability.consume( - prepared_generation_id=authority_facts.prepared_generation_id, - predecessor_submission_id=authority_facts.predecessor_submission_id, - checker_remediation_manifest_id=authority_facts.checker_remediation_manifest_id, - effective_plan_sha256=authority_facts.effective_plan_sha256, - archive_sha256=authority_facts.archive_sha256, - semantic_manifest_sha256=authority_facts.semantic_manifest_sha256, - storage_scheme=authority_facts.storage_scheme, - ) - if consumed_evidence_id != authority_facts.pre_submit_evidence_set_id: - raise ArtifactAdmissionRelationshipError( - "submission bundle pass capability evidence changed" - ) prepared_authorization = await submission_prepared_authorization.prepare_final( facts=authority_facts ) @@ -2091,6 +2078,7 @@ async def admit( else {} ), "pre_submit_evidence_set_id": facts.pre_submit_evidence_set_id, + "selected_pre_submit_run_id": facts.selected_pre_submit_run_id, "sha256": commitment.sha256, "byte_count": commitment.byte_count, "media_type": commitment.media_type, @@ -2127,9 +2115,9 @@ async def admit( counter.scope_type, counter.counted_bytes, counter.limit_bytes ) if replay is not None: - if facts.pre_submit_evidence_set_id is not None: - intent = await self._repo.get_submission_bundle_intent_by_evidence( - facts.pre_submit_evidence_set_id + if facts.selected_pre_submit_run_id is not None: + intent = await self._repo.get_submission_bundle_intent_by_selected_run( + facts.selected_pre_submit_run_id ) if intent is None or intent.put_attempt_id != replay.id: raise ArtifactAdmissionConfigurationError( @@ -2181,11 +2169,41 @@ async def admit( terminal_at=None, ) await self._repo.add_put_attempt(attempt, charges) - if facts.pre_submit_evidence_set_id is not None: + if facts.selected_pre_submit_run_id is not None: + selected_request = ( + request.custody.selected_reservation + if type(request) is SubmissionBundleArtifactAdmissionRequest + else None + ) await self._repo.add_submission_bundle_intent( SubmissionBundleDurableIntent( id=str(new_record_id()), pre_submit_evidence_set_id=facts.pre_submit_evidence_set_id, + selected_pre_submit_run_id=facts.selected_pre_submit_run_id, + selected_request_json=( + selected_request.canonical_body() + if selected_request is not None + else None + ), + selected_request_digest=( + selected_request.request_digest + if selected_request is not None + else None + ), + selected_terminal_json=( + request.custody.selected_terminal_body + if selected_request is not None + and type(request) + is SubmissionBundleArtifactAdmissionRequest + else None + ), + selected_terminal_digest=( + request.custody.selected_terminal_digest + if selected_request is not None + and type(request) + is SubmissionBundleArtifactAdmissionRequest + else None + ), put_attempt_id=attempt.id, ) ) @@ -2350,15 +2368,18 @@ async def _submission_bundle_replay_attempt( raise ArtifactAdmissionRelationshipError( "submission bundle replay intent is unavailable" ) - prior = await self._repo.lock_pre_submit_evidence_set(intent.pre_submit_evidence_set_id) - fresh = await self._repo.lock_pre_submit_evidence_set( - str(current.pre_submit_evidence_set_id) - ) + reservation = request.custody.selected_reservation attempt = await self._repo.lock_put_attempt(intent.put_attempt_id) if ( - prior is None - or fresh is None - or attempt is None + attempt is None + or intent.selected_pre_submit_run_id != str(reservation.attempt_id) + or intent.pre_submit_evidence_set_id is not None + or intent.selected_request_json != reservation.canonical_body() + or intent.selected_request_digest != reservation.request_digest + or intent.selected_terminal_json + != request.custody.selected_terminal_body + or intent.selected_terminal_digest + != request.custody.selected_terminal_digest or attempt.producer_request_type != "submission_bundle" or attempt.status not in { @@ -2370,240 +2391,162 @@ async def _submission_bundle_replay_attempt( or attempt.sha256 != current.archive_sha256 or attempt.byte_count != current.archive_byte_count or attempt.media_type != current.media_type - or self._submission_replay_lineage(prior) != self._submission_replay_lineage(fresh) - or ( - prior.actor_profile_id, - prior.identity_link_id, - prior.project_id, - prior.task_id, - prior.assignment_id, - prior.predecessor_submission_id, - prior.predecessor_submission_version, - prior.checker_remediation_manifest_id, - prior.guide_id, - prior.guide_version, - prior.source_snapshot_id, - prior.source_snapshot_sha256, - prior.locked_artifact_policy_sha256, - prior.locked_checker_policy_sha256, - prior.effective_plan_sha256, - prior.semantic_manifest_sha256, - prior.archive_sha256, - prior.archive_byte_count, - prior.storage_scheme, - ) - != ( - str(current.actor_profile_id), - str(current.identity_link_id), - str(current.project_id), - str(current.task_id), - str(current.assignment_id), - str(current.predecessor_submission_id) - if current.predecessor_submission_id is not None - else None, - current.predecessor_submission_version, - current.checker_remediation_manifest_id, - str(current.guide_id), - current.guide_version, - str(current.source_snapshot_id), - current.source_snapshot_sha256, - current.effective_policy_sha256, - current.pre_submit_policy_sha256, - current.effective_plan_sha256, - current.semantic_manifest_sha256, - current.archive_sha256, - current.archive_byte_count, - current.storage_scheme, - ) ): raise ArtifactAdmissionRelationshipError( - "submission bundle replay intent does not match fresh custody" + "selected submission bundle replay intent does not match custody" ) return attempt - @staticmethod - def _submission_replay_lineage(evidence: PreSubmitEvidenceSet) -> tuple[object, ...]: - """Return every publication-relevant evidence fact except fresh issuance identity.""" - return ( - evidence.actor_profile_id, - evidence.identity_link_id, - evidence.project_id, - evidence.task_id, - evidence.assignment_id, - evidence.predecessor_submission_id, - evidence.predecessor_submission_version, - evidence.checker_remediation_manifest_id, - evidence.archive_sha256, - evidence.archive_byte_count, - evidence.semantic_manifest_id, - evidence.semantic_manifest_sha256, - evidence.guide_id, - evidence.guide_version, - evidence.source_snapshot_id, - evidence.source_snapshot_sha256, - evidence.locked_guide_sha256, - evidence.effective_policy_id, - evidence.locked_artifact_policy_sha256, - evidence.pre_submit_policy_id, - evidence.locked_checker_policy_sha256, - evidence.effective_plan_sha256, - evidence.catalogue_id, - evidence.catalogue_version, - evidence.catalogue_manifest_sha256, - evidence.storage_scheme, - evidence.terminal_status, - evidence.eligible, - evidence.result_count, - evidence.result_manifest_sha256, - ) - async def _submission_bundle_facts( self, request: SubmissionBundleArtifactAdmissionRequest, *, task_contexts: TaskSubmissionContextPort, project_contexts: ProjectLockedPolicyContextPort, ) -> tuple[SubmissionBundleDurableIntentAuthorityFacts, _AdmissionFacts]: - """Lock exact passing evidence and current TASK-owned lineage.""" - from app.modules.artifacts.pre_submit_evidence import PreSubmitPassCapability + """Revalidate the sole external PRE custody under current owner locks.""" from app.modules.artifacts.submission_custody import SubmissionBundlePreparedCustody if type(request.custody) is not SubmissionBundlePreparedCustody: raise ArtifactAuthorityDeniedError("submission bundle prepared custody is unavailable") - pass_capability = request.custody.pass_capability - if type(pass_capability) is not PreSubmitPassCapability: - raise ArtifactAuthorityDeniedError("submission bundle pass capability is unavailable") - evidence = await self._repo.lock_pre_submit_evidence_set( - str(request.pre_submit_evidence_set_id) + return await self._selected_submission_bundle_facts( + request, task_contexts=task_contexts, project_contexts=project_contexts, ) + + async def _selected_submission_bundle_facts( + self, + request: SubmissionBundleArtifactAdmissionRequest, + *, + task_contexts: TaskSubmissionContextPort, + project_contexts: ProjectLockedPolicyContextPort, + ) -> tuple[SubmissionBundleDurableIntentAuthorityFacts, _AdmissionFacts]: + """Revalidate selected terminal custody against current TASK and PROJECT locks.""" + reservation = request.custody.selected_reservation + lineage = reservation.admission_lineage commitment = request.source.commitment if ( - evidence is None - or evidence.terminal_status != "passed" - or not evidence.eligible - or evidence.prepared_generation_id != str(request.custody.prepared_generation_id) - or evidence.archive_sha256 != commitment.sha256 - or evidence.archive_byte_count != commitment.byte_count + lineage is None + or request.pre_submit_evidence_set_id is not None + or request.selected_pre_submit_run_id != reservation.attempt_id + or request.selected_terminal_digest + != request.custody.selected_terminal_digest + or reservation.prepared_generation_id + != request.custody.prepared_generation_id + or reservation.archive_sha256 != commitment.sha256 + or reservation.archive_byte_count != commitment.byte_count or commitment.media_type != "application/zip" - or evidence.predecessor_submission_id - != ( - str(pass_capability.predecessor_submission_id) - if pass_capability.predecessor_submission_id is not None - else None - ) - or evidence.checker_remediation_manifest_id - != pass_capability.checker_remediation_manifest_id - or evidence.effective_plan_sha256 != pass_capability.effective_plan_sha256 - or evidence.semantic_manifest_sha256 != pass_capability.semantic_manifest_sha256 - or evidence.storage_scheme != pass_capability.storage_scheme + or canonical_json_hash(lineage.semantic_manifest_body) + != reservation.semantic_manifest_sha256 ): raise ArtifactAdmissionRelationshipError( - "submission bundle passing evidence is unavailable" + "selected submission bundle passing evidence is unavailable" ) try: task = await task_contexts.lock_submission_context( TaskSubmissionContextRequest( - task_id=UUID(evidence.task_id), - assignment_id=UUID(evidence.assignment_id), - contributor_id=UUID(evidence.actor_profile_id), - predecessor_submission_id=(UUID(evidence.predecessor_submission_id) - if evidence.predecessor_submission_id is not None else None), + task_id=reservation.task_id, + assignment_id=reservation.assignment_id, + contributor_id=reservation.actor_profile_id, + predecessor_submission_id=lineage.predecessor_submission_id, ) ) - references = task.locked_project_context locked = await project_contexts.lock_locked_policy_context( ProjectLockedPolicyContextRequest( - project_id=references.project_id, guide_version=references.guide_version, - source_snapshot_id=references.source_snapshot_id, - source_snapshot_hash=references.source_snapshot_hash, - effective_policy_id=references.effective_policy_id, - effective_policy_hash=references.effective_policy_hash, - pre_submit_policy_id=references.pre_submit_policy_id, - pre_submit_policy_bundle_hash=references.pre_submit_policy_bundle_hash, + project_id=reservation.project_id, + guide_version=lineage.guide_version, + source_snapshot_id=lineage.source_snapshot_id, + source_snapshot_hash=lineage.source_snapshot_sha256, + effective_policy_id=lineage.effective_policy_id, + effective_policy_hash=lineage.effective_policy_sha256, + pre_submit_policy_id=lineage.pre_submit_policy_id, + pre_submit_policy_bundle_hash=lineage.pre_submit_policy_sha256, ) ) except (TaskSubmissionContextUnavailable, ProjectLockedPolicyContextUnavailable) as exc: - raise ArtifactAdmissionRelationshipError("pre_submit_locked_context_changed") from exc - guide_hash = canonical_json_hash({ - "domain": "workstream.locked_task_guide.v1", - "project_id": str(locked.project_id), "guide_id": str(locked.guide_id), - "guide_version": locked.guide_version, - "source_snapshot_id": str(locked.source_snapshot_id), - "source_snapshot_sha256": locked.source_snapshot_hash, - }) + raise ArtifactAdmissionRelationshipError( + "selected_pre_submit_locked_context_changed" + ) from exc + predecessor = task.predecessor if ( - locked.project_id != UUID(evidence.project_id) - or (task.predecessor.version if task.predecessor else None) - != evidence.predecessor_submission_version + task.contributor_id != reservation.actor_profile_id + or task.task_id != reservation.task_id + or task.assignment_id != reservation.assignment_id + or (predecessor.submission_id if predecessor is not None else None) + != lineage.predecessor_submission_id + or (predecessor.version if predecessor is not None else None) + != lineage.predecessor_submission_version or task.checker_remediation_manifest_id - != evidence.checker_remediation_manifest_id - or task.submitter_contribution_policy_version_id - != locked.activation_receipt.command.contribution_policy_version_id - or locked.guide_id != UUID(evidence.guide_id) - or locked.guide_version != evidence.guide_version - or locked.source_snapshot_id != UUID(evidence.source_snapshot_id) - or locked.source_snapshot_hash != evidence.source_snapshot_sha256 - or guide_hash != evidence.locked_guide_sha256 - or locked.effective_policy_id != UUID(evidence.effective_policy_id) - or locked.effective_policy_hash != evidence.locked_artifact_policy_sha256 - or locked.pre_submit_policy_id != UUID(evidence.pre_submit_policy_id) - or locked.pre_submit_policy_bundle_hash != evidence.locked_checker_policy_sha256 + != lineage.checker_remediation_manifest_id + or locked.project_id != reservation.project_id + or locked.guide_id != lineage.guide_id + or locked.guide_version != lineage.guide_version + or locked.source_snapshot_id != lineage.source_snapshot_id + or locked.source_snapshot_hash != lineage.source_snapshot_sha256 + or locked.effective_policy_id != lineage.effective_policy_id + or locked.effective_policy_hash != lineage.effective_policy_sha256 + or locked.pre_submit_policy_id != lineage.pre_submit_policy_id + or locked.pre_submit_policy_bundle_hash + != lineage.pre_submit_policy_sha256 + or reservation.policy_sha256 != lineage.effective_plan_sha256 ): - raise ArtifactAdmissionRelationshipError("submission bundle locked context changed") + raise ArtifactAdmissionRelationshipError( + "selected submission bundle locked context changed" + ) operation_identity = canonical_json_hash( { "request_type": "submission_bundle", - "pre_submit_evidence_set_id": evidence.id, + "selected_pre_submit_run_id": str(reservation.attempt_id), } ) authority_facts = SubmissionBundleDurableIntentAuthorityFacts( - actor_profile_id=UUID(evidence.actor_profile_id), - identity_link_id=UUID(evidence.identity_link_id), - project_id=UUID(evidence.project_id), - task_id=UUID(evidence.task_id), - assignment_id=UUID(evidence.assignment_id), - predecessor_submission_id=( - UUID(evidence.predecessor_submission_id) - if evidence.predecessor_submission_id is not None - else None - ), - predecessor_submission_version=evidence.predecessor_submission_version, - checker_remediation_manifest_id=evidence.checker_remediation_manifest_id, - pre_submit_evidence_set_id=UUID(evidence.id), - prepared_generation_id=UUID(evidence.prepared_generation_id), - guide_id=UUID(evidence.guide_id), - guide_version=evidence.guide_version, - source_snapshot_id=UUID(evidence.source_snapshot_id), - source_snapshot_sha256=evidence.source_snapshot_sha256, - effective_policy_id=UUID(evidence.effective_policy_id), - effective_policy_sha256=evidence.locked_artifact_policy_sha256, - pre_submit_policy_id=UUID(evidence.pre_submit_policy_id), - pre_submit_policy_sha256=evidence.locked_checker_policy_sha256, - effective_plan_sha256=evidence.effective_plan_sha256, - semantic_manifest_id=UUID(evidence.semantic_manifest_id), - semantic_manifest_sha256=evidence.semantic_manifest_sha256, - archive_sha256=evidence.archive_sha256, - archive_byte_count=evidence.archive_byte_count, + actor_profile_id=reservation.actor_profile_id, + identity_link_id=reservation.identity_link_id, + project_id=reservation.project_id, + task_id=reservation.task_id, + assignment_id=reservation.assignment_id, + predecessor_submission_id=lineage.predecessor_submission_id, + predecessor_submission_version=lineage.predecessor_submission_version, + checker_remediation_manifest_id=lineage.checker_remediation_manifest_id, + pre_submit_evidence_set_id=None, + prepared_generation_id=reservation.prepared_generation_id, + guide_id=lineage.guide_id, + guide_version=lineage.guide_version, + source_snapshot_id=lineage.source_snapshot_id, + source_snapshot_sha256=lineage.source_snapshot_sha256, + effective_policy_id=lineage.effective_policy_id, + effective_policy_sha256=lineage.effective_policy_sha256, + pre_submit_policy_id=lineage.pre_submit_policy_id, + pre_submit_policy_sha256=lineage.pre_submit_policy_sha256, + effective_plan_sha256=lineage.effective_plan_sha256, + semantic_manifest_id=lineage.semantic_manifest_id, + semantic_manifest_sha256=reservation.semantic_manifest_sha256, + semantic_manifest_body=lineage.semantic_manifest_body, + packet_sha256=lineage.packet_sha256, + archive_sha256=reservation.archive_sha256, + archive_byte_count=reservation.archive_byte_count, media_type=commitment.media_type, - storage_scheme=evidence.storage_scheme, + storage_scheme=lineage.storage_scheme, operation_identity=operation_identity, replay_durable_intent_id=request.replay_durable_intent_id, + selected_pre_submit_run_id=reservation.attempt_id, + selected_terminal_digest=request.custody.selected_terminal_digest, ) return authority_facts, _AdmissionFacts( request_type="submission_bundle", producer_type="actor_profile", - producer_ref=evidence.actor_profile_id, - project_id=evidence.project_id, - guide_id=evidence.guide_id, - task_id=evidence.task_id, + producer_ref=str(reservation.actor_profile_id), + project_id=str(reservation.project_id), + guide_id=str(lineage.guide_id), + task_id=str(reservation.task_id), guide_source_item_id=None, - guide_source_snapshot_id=evidence.source_snapshot_id, + guide_source_snapshot_id=str(lineage.source_snapshot_id), checker_run_id=None, logical_role=None, submission_id=None, submission_version=None, checker_request_digest=None, checker_request_digest_facts=None, - pre_submit_evidence_set_id=evidence.id, + pre_submit_evidence_set_id=None, operation_identity=operation_identity, + selected_pre_submit_run_id=str(reservation.attempt_id), ) async def _require_active_human_actor(self, context: AuthorizationContext) -> None: diff --git a/backend/app/modules/artifacts/submission_admission.py b/backend/app/modules/artifacts/submission_admission.py index d536b039f..bdceca6ee 100644 --- a/backend/app/modules/artifacts/submission_admission.py +++ b/backend/app/modules/artifacts/submission_admission.py @@ -4,9 +4,9 @@ from collections.abc import Callable from contextlib import AbstractAsyncContextManager, AsyncExitStack -from dataclasses import dataclass, replace -import json +from dataclasses import dataclass, field, replace from uuid import UUID +from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from sqlalchemy import func, select @@ -14,7 +14,6 @@ from app.modules.artifacts.api import ( SubmissionBundlePreparationCheckFailed, - SubmissionBundleFile, SubmissionBundlePreparationRejected, SubmissionBundlePreparationInfrastructureUnavailable, SubmissionBundlePreparationRequest, @@ -30,14 +29,9 @@ ArtifactReplica, ArtifactVerificationJob, ArtifactVerificationReceipt, - PreSubmitEvidenceSet, SubmissionBundleAdmission, SubmissionBundleDurableIntent, ) -from app.modules.artifacts.pre_submit_evidence import ( - PreSubmitEvidenceConflict, - PreSubmitPassCapability, -) from app.modules.artifacts.preparation import ArtifactPreparationService from app.modules.artifacts.schemas import ( ArtifactAdmissionResult, @@ -60,21 +54,32 @@ from app.modules.artifacts.submission_manifest import ( SubmissionCanonicalPredecessor, build_submission_manifest, - evaluate_submission_change, ) from app.modules.artifacts.submission_materialization import ( PreparedBundleMaterializationRequest, PreparedBundleMaterializationService, - PreparedBundlePreSubmitEvidenceService, - PreSubmissionEvaluationPort, ) -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationContent -from app.modules.checkers.api import ( - EffectivePreSubmissionExecutionPlan, - EffectivePreSubmissionPlanLineage, - EffectivePreSubmissionPlanningPort, - SubmissionPacketView, +from app.modules.artifacts.selected_pre_submit_admission import ( + bind_selected_pre_submit_admission, + build_selected_pre_submit_reservation, + selected_pre_submit_attempt_request_digest, +) +from app.modules.artifacts.selected_pre_submit_execution import ( + SelectedPreSubmitExecutionService, +) +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitCompletion, + SelectedPreSubmitCoordinationPort, + SelectedPreSubmitCurrentFacts, + SelectedPreSubmitReservationFacts, + SelectedPreSubmitRequestConflict, + SelectedPreSubmitUnavailable, +) +from app.modules.checkers.api.external_policy import ( + LockedExternalCheckerPolicies, + selected_pre_submit_execution_budget_ms, ) +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView from app.modules.projects.api import ( ProjectLockedPolicyContextFacts, ProjectLockedPolicyContextPort, @@ -106,8 +111,12 @@ class SubmissionBundleDurablePutRequest: prepared_authorization: object prepared_artifact: PreparedArtifact - pass_capability: PreSubmitPassCapability replay_durable_intent_id: UUID | None = None + selected_completion: SelectedPreSubmitCompletion = field(kw_only=True) + + def __post_init__(self) -> None: + if type(self.selected_completion) is not SelectedPreSubmitCompletion: + raise ValueError("submission bundle durable PRE custody is invalid") @dataclass(frozen=True, slots=True) @@ -115,11 +124,11 @@ class SubmissionBundleDurablePutResult: """Bounded durable operation result without provider coordinates.""" put_attempt_id: UUID - pre_submit_evidence_set_id: UUID operation_identity: str status: str replayed: bool admission_id: UUID | None + selected_pre_submit_run_id: UUID class SubmissionBundleDurablePutService: @@ -160,22 +169,25 @@ async def admit_in_transaction( or not transaction.is_active or self._session.in_nested_transaction() or type(prepared) is not PreparedArtifact - or type(request.pass_capability) is not PreSubmitPassCapability + or type(request.selected_completion) is not SelectedPreSubmitCompletion ): if type(prepared) is PreparedArtifact: await prepared.close() raise RuntimeError("submission bundle durable transaction is unavailable") try: - evidence_set_id = request.pass_capability.evidence_set_id - custody = SubmissionBundlePreparedCustody._from_live_preparation( - prepared=prepared, - capability=request.pass_capability, + custody = SubmissionBundlePreparedCustody._from_selected_pre_submit( + prepared=prepared, completion=request.selected_completion, ) + selected_run_id = custody.selected_reservation.attempt_id admission = await self._admission.admit( SubmissionBundleArtifactAdmissionRequest( - pre_submit_evidence_set_id=evidence_set_id, + pre_submit_evidence_set_id=None, custody=custody, replay_durable_intent_id=request.replay_durable_intent_id, + selected_pre_submit_run_id=selected_run_id, + selected_terminal_digest=( + custody.selected_terminal_digest + ), ), submission_prepared_authorization=self._authorization, submission_task_contexts=self._task_contexts, @@ -183,7 +195,7 @@ async def admit_in_transaction( prepared_authorization=request.prepared_authorization, existing_transaction=True, ) - return prepared, evidence_set_id, admission + return prepared, selected_run_id, admission except BaseException: await prepared.close() raise @@ -191,7 +203,7 @@ async def admit_in_transaction( async def publish_after_commit( self, prepared: PreparedArtifact, - evidence_set_id: UUID, + lineage_id: UUID, admission: ArtifactAdmissionResult, ) -> SubmissionBundleDurablePutResult: """Hand the exact ZIP to storage only after the durable transaction ended.""" @@ -209,20 +221,20 @@ async def publish_after_commit( if admission_id is not None: return SubmissionBundleDurablePutResult( put_attempt_id=admission.attempt_id, - pre_submit_evidence_set_id=evidence_set_id, operation_identity=admission.operation_identity, status="ready", replayed=True, admission_id=admission_id, + selected_pre_submit_run_id=lineage_id, ) if admission.replayed and admission.status == "object_confirmed": return SubmissionBundleDurablePutResult( put_attempt_id=admission.attempt_id, - pre_submit_evidence_set_id=evidence_set_id, operation_identity=admission.operation_identity, status="object_confirmed", replayed=True, admission_id=None, + selected_pre_submit_run_id=lineage_id, ) if admission.replayed: status = await self._storage.resume_committed_put( @@ -241,11 +253,11 @@ async def publish_after_commit( ) return SubmissionBundleDurablePutResult( put_attempt_id=admission.attempt_id, - pre_submit_evidence_set_id=evidence_set_id, operation_identity=admission.operation_identity, status=status, replayed=admission.replayed, admission_id=admission_id, + selected_pre_submit_run_id=lineage_id, ) finally: await prepared.close() @@ -291,6 +303,8 @@ async def publish_verified( ) if intent is None: raise SubmissionBundleAdmissionPublicationError("durable intent is unavailable") + if intent.pre_submit_evidence_set_id is not None or intent.selected_pre_submit_run_id is None: + raise SubmissionBundleAdmissionPublicationError("external PRE durable intent is unavailable") existing = await self._session.scalar( select(SubmissionBundleAdmission) .where(SubmissionBundleAdmission.durable_intent_id == intent.id) @@ -298,11 +312,29 @@ async def publish_verified( ) if existing is not None: return existing - evidence = await self._session.scalar( - select(PreSubmitEvidenceSet) - .where(PreSubmitEvidenceSet.id == intent.pre_submit_evidence_set_id) - .with_for_update(key_share=True) - ) + try: + selected = SelectedPreSubmitReservationFacts.from_canonical_body( + intent.selected_request_json + ) + except (TypeError, ValueError) as exc: + raise SubmissionBundleAdmissionPublicationError( + "selected PRE request snapshot is invalid" + ) from exc + terminal = intent.selected_terminal_json + if ( + selected.admission_lineage is None + or selected.attempt_id != UUID(intent.selected_pre_submit_run_id) + or selected.request_digest != intent.selected_request_digest + or type(terminal) is not dict + or canonical_json_hash(terminal) != intent.selected_terminal_digest + or terminal.get("request_digest") != selected.request_digest + or terminal.get("outcome") != "completed" + or type(terminal.get("execution")) is not dict + or terminal["execution"].get("eligible") is not True + ): + raise SubmissionBundleAdmissionPublicationError( + "selected PRE terminal snapshot is invalid" + ) replica = await self._session.scalar( select(ArtifactReplica) .where(ArtifactReplica.id == job.replica_id) @@ -322,68 +354,93 @@ async def publish_verified( if replica is not None else None ) - if not self._matches_verified_lineage(evidence, attempt, job, replica, content, receipt): + lineage = selected.admission_lineage + if not self._matches_verified_bytes( + attempt, job, replica, content, receipt, + project_id=str(selected.project_id), task_id=str(selected.task_id), + actor_profile_id=str(selected.actor_profile_id), + archive_sha256=selected.archive_sha256, + archive_byte_count=selected.archive_byte_count, + ): raise SubmissionBundleAdmissionPublicationError( "verified submission bundle lineage does not match" ) operation_receipt_id, observation_receipt_id = await self._write_receipt_ids(attempt) - assert evidence is not None and replica is not None and content is not None - assert receipt is not None + assert replica is not None and content is not None and receipt is not None + assert lineage is not None now = await self._session.scalar(select(func.now())) admission = SubmissionBundleAdmission( - id=str(new_record_id()), - durable_intent_id=intent.id, - pre_submit_evidence_set_id=evidence.id, - put_attempt_id=attempt.id, - artifact_content_id=content.id, - verified_replica_id=replica.id, - verification_receipt_id=receipt.id, + id=str(new_record_id()), durable_intent_id=intent.id, + pre_submit_evidence_set_id=None, + selected_pre_submit_run_id=str(selected.attempt_id), + put_attempt_id=attempt.id, artifact_content_id=content.id, + verified_replica_id=replica.id, verification_receipt_id=receipt.id, put_operation_receipt_id=operation_receipt_id, put_observation_receipt_id=observation_receipt_id, - actor_profile_id=evidence.actor_profile_id, - identity_link_id=evidence.identity_link_id, - project_id=evidence.project_id, - task_id=evidence.task_id, - assignment_id=evidence.assignment_id, - predecessor_submission_id=evidence.predecessor_submission_id, - predecessor_submission_version=evidence.predecessor_submission_version, - checker_remediation_manifest_id=evidence.checker_remediation_manifest_id, - locked_policy_context_hash=evidence.locked_policy_context_hash, - semantic_manifest_id=evidence.semantic_manifest_id, - semantic_manifest_sha256=evidence.semantic_manifest_sha256, - archive_sha256=evidence.archive_sha256, - archive_byte_count=evidence.archive_byte_count, - status="ready", - ready_at=now, - consumed_at=None, - consumed_by_submission_id=None, - stale_at=None, - stale_reason=None, + actor_profile_id=str(selected.actor_profile_id), + identity_link_id=str(selected.identity_link_id), + project_id=str(selected.project_id), task_id=str(selected.task_id), + assignment_id=str(selected.assignment_id), + predecessor_submission_id=( + str(lineage.predecessor_submission_id) + if lineage.predecessor_submission_id is not None else None + ), + predecessor_submission_version=lineage.predecessor_submission_version, + checker_remediation_manifest_id=( + str(lineage.checker_remediation_manifest_id) + if lineage.checker_remediation_manifest_id is not None else None + ), + locked_policy_context_hash=lineage.locked_policy_context_hash, + guide_id=str(lineage.guide_id), guide_version=lineage.guide_version, + source_snapshot_id=str(lineage.source_snapshot_id), + source_snapshot_sha256=lineage.source_snapshot_sha256, + effective_policy_id=str(lineage.effective_policy_id), + effective_policy_sha256=lineage.effective_policy_sha256, + pre_submit_policy_id=str(lineage.pre_submit_policy_id), + pre_submit_policy_sha256=lineage.pre_submit_policy_sha256, + effective_plan_sha256=lineage.effective_plan_sha256, + selected_rules_sha256=lineage.rules_sha256, + selected_post_submit_policy_sha256=lineage.post_submit_policy_sha256, + selected_post_submit_policy_body=lineage.post_submit_policy_body, + packet_sha256=lineage.packet_sha256, + semantic_manifest_id=str(lineage.semantic_manifest_id), + semantic_manifest_sha256=selected.semantic_manifest_sha256, + semantic_manifest_body=lineage.semantic_manifest_body, + archive_sha256=selected.archive_sha256, + archive_byte_count=selected.archive_byte_count, + status="ready", ready_at=now, consumed_at=None, + consumed_by_submission_id=None, stale_at=None, stale_reason=None, ) self._session.add(admission) await self._session.flush() return admission @staticmethod - def _matches_verified_lineage(evidence, attempt, job, replica, content, receipt) -> bool: + def _matches_verified_bytes( + attempt, + job, + replica, + content, + receipt, + *, + project_id: str, + task_id: str, + actor_profile_id: str, + archive_sha256: str, + archive_byte_count: int, + ) -> bool: return bool( - evidence is not None - and evidence.terminal_status == "passed" - and evidence.eligible - and replica is not None + replica is not None and content is not None and receipt is not None and receipt.verification_job_id == job.id and receipt.execution_generation == job.execution_generation and receipt.outcome == "verified" - and receipt.observed_sha256 - == attempt.sha256 - == content.sha256 - == evidence.archive_sha256 + and receipt.observed_sha256 == attempt.sha256 == content.sha256 == archive_sha256 and receipt.observed_byte_count == attempt.byte_count == content.byte_count - == evidence.archive_byte_count + == archive_byte_count and job.originating_put_attempt_id == attempt.id and job.replica_id == replica.id and attempt.replica_id == replica.id @@ -391,12 +448,13 @@ def _matches_verified_lineage(evidence, attempt, job, replica, content, receipt) and replica.verification_state == "verified" and replica.availability_state == "available" and replica.integrity_state == "valid" - and attempt.project_id == evidence.project_id - and attempt.task_id == evidence.task_id - and attempt.producer_ref == evidence.actor_profile_id + and attempt.project_id == project_id + and attempt.task_id == task_id + and attempt.producer_ref == actor_profile_id and attempt.media_type == "application/zip" ) + async def _write_receipt_ids( self, attempt: ArtifactPutAttempt ) -> tuple[str | None, str | None]: @@ -452,15 +510,12 @@ async def current_submission_bundle_admission_id( class SubmissionBundlePreparationRuntime: preparation: ArtifactPreparationService inspector: SubmissionArchiveInspector - catalogue: EffectivePreSubmissionPlanningPort materialization: PreparedBundleMaterializationService - evidence: PreparedBundlePreSubmitEvidenceService - checker_service: PreSubmissionEvaluationPort durable_put: SubmissionBundleDurablePutService - evaluation_content: Callable[ - [TaskSubmissionContextFacts, ProjectLockedPolicyContextFacts, SubmissionPacketView, - tuple[SubmissionBundleFile, ...], str], PostSubmissionEvaluationContent, - ] + selected_coordinator: SelectedPreSubmitCoordinationPort | None = None + selected_execution: SelectedPreSubmitExecutionService | None = None + storage_scheme: str | None = None + artifact_deadline_ms: int = 0 class PreparedSubmissionBundlePreparationCommand: @@ -497,14 +552,16 @@ async def prepare( if request.media_type.partition(";")[0].strip().lower() != "application/zip": raise SubmissionBundlePreparationRejected("submission_bundle_media_type_invalid") async with self._runtime_factory() as runtime, AsyncExitStack() as scratch_cleanup: + if ( + runtime.selected_coordinator is None + or runtime.selected_execution is None + or runtime.storage_scheme is None + ): + raise RuntimeError("selected PRE runtime is unavailable") async with self._session.begin(): task_context, project_context = await self._lock_authorized_context(request) - plan = self._compile_plan( - task_context, - project_context, - runtime.catalogue, - ) predecessor = await self._load_predecessor(task_context) + predecessor_rules_sha256 = await self._load_predecessor_rules(task_context) remediation_manifest_id = task_context.checker_remediation_manifest_id prepared = await runtime.preparation.prepare( request.byte_source, @@ -517,34 +574,65 @@ async def prepare( raise SubmissionBundlePreparationRejected( "submission_bundle_preparation_context_changed" ) - materialization_handle = await runtime.materialization.prepare_authorization( - task_id=request.task_id, - assignment_id=request.assignment_id, - submission_artifact_policy_id=project_context.effective_policy_id, - checker_policy_id=project_context.pre_submit_policy_id, - prepared_artifact=prepared, - effective_plan=plan, - idempotency_key=request.idempotency_key, + policies = LockedExternalCheckerPolicies.model_validate_json( + project_context.external_checker_policies.value + ) + materialization_handle = ( + await runtime.materialization.prepare_selected_authorization( + task=task_context, + project=project_context, + prepared_artifact=prepared, + external_policy=policies.pre_submit, + idempotency_key=request.idempotency_key, + ) ) inspection = await prepared.inspect(runtime.inspector) manifest = build_submission_manifest(inspection) packet = SubmissionPacketView( - summary=request.summary, contributor_attestation=request.contributor_attestation, + summary=request.summary, + contributor_attestation=request.contributor_attestation, ) - try: - runtime.evaluation_content( - task_context, project_context, packet, manifest.file_facts(), prepared.commitment.sha256, - ) - except (ValueError, TypeError, KeyError) as exc: - raise SubmissionBundlePreparationRejected( - "submission_evaluation_content_invalid" - ) from exc - change_gate = evaluate_submission_change( + selected = bind_selected_pre_submit_admission( + task=task_context, + project=project_context, commitment=prepared.commitment, manifest=manifest, predecessor=predecessor, - predecessor_exists=request.predecessor_submission_id is not None, - current_predecessor=predecessor, + predecessor_rules_sha256=predecessor_rules_sha256, + predecessor_exists=(request.predecessor_submission_id is not None), + packet=packet, + ) + attempt_id = new_record_id() + reservation = build_selected_pre_submit_reservation( + actor_profile_id=request.actor.actor_profile_id, + identity_link_id=request.actor.identity_link_id, + task=task_context, + project=project_context, + prepared_generation_id=prepared.generation_id, + attempt_id=attempt_id, + idempotency_key=request.idempotency_key, + attempt_request_digest=( + selected_pre_submit_attempt_request_digest( + actor_profile_id=request.actor.actor_profile_id, + identity_link_id=request.actor.identity_link_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + predecessor_submission_id=(request.predecessor_submission_id), + idempotency_key=request.idempotency_key, + archive_sha256=prepared.commitment.sha256, + archive_byte_count=prepared.commitment.byte_count, + packet_sha256=packet.sha256, + ) + ), + commitment=prepared.commitment, + manifest=manifest, + packet=packet, + selected=selected, + storage_scheme=runtime.storage_scheme, + execution_budget_ms=selected_pre_submit_execution_budget_ms( + selected.policy, + artifact_deadline_ms=runtime.artifact_deadline_ms, + ), ) materialization_request = PreparedBundleMaterializationRequest( prepared_authorization=materialization_handle, @@ -559,51 +647,79 @@ async def prepare( ), checker_remediation_manifest_id=remediation_manifest_id, prepared_artifact=prepared, - effective_plan=plan, inspection=inspection, manifest=manifest, - change_gate=change_gate, + change_gate=selected.change_gate, packet=packet, + external_policy=selected.policy, + default_checker_input=selected.default_input, + selected_reservation=reservation, ) - reserved = await runtime.evidence.reserve( - materialization_request, preparation_request=request, - ) - evidence = await runtime.checker_service.evaluate_pre_submission( - replace(materialization_request, prepared_authorization=None), - reserved, preparation_request=request, + current = await runtime.selected_coordinator.read_current(reservation) + if current is None: + await runtime.materialization.authorize_inspected(materialization_request) + reserved_id = await runtime.selected_coordinator.reserve(reservation) + if reserved_id != reservation.attempt_id: + raise RuntimeError("selected PRE reservation identity changed") + if current is not None: + if current.status == "running": + try: + recovered = await runtime.selected_execution.recover_expired( + run_id=current.run_id, + reservation=current.reservation, + ) + except SelectedPreSubmitUnavailable: + pass + else: + current = SelectedPreSubmitCurrentFacts( + run_id=current.run_id, + reservation=current.reservation, + status="infrastructure_failed", + terminal=recovered.terminal, + ) + return await self._selected_replay_result(current) + completion = await runtime.selected_execution.execute( + reservation=reservation, + materialization=replace( + materialization_request, + prepared_authorization=None, + ), ) - if not evidence.execution.eligible: - raise SubmissionBundlePreparationCheckFailed( - evidence.execution.checker_facts + terminal = completion.terminal + if terminal.outcome == "infrastructure_failed": + raise SubmissionBundlePreparationInfrastructureUnavailable( + terminal.infrastructure_failure_code + or "pre_submission_checked_custody_unavailable" + ) + if terminal.execution is None: + raise SubmissionBundlePreparationInfrastructureUnavailable( + "pre_submission_checked_custody_unavailable" + ) + if not terminal.execution.eligible: + raise SubmissionBundlePreparationCheckFailed(terminal.execution) + if completion.capability is None: + raise SubmissionBundlePreparationInfrastructureUnavailable( + "pre_submission_checked_custody_unavailable" ) - if evidence.pass_capability is None: - replay = await self._existing_durable_result(evidence.evidence.evidence_set_id) - if replay is None: - raise SubmissionBundlePreparationInfrastructureUnavailable( - "pre_submission_checked_custody_unavailable" - ) - return replay - replay_intent_id = await self._matching_replay_intent( - evidence.evidence.evidence_set_id - ) async with self._authority.transaction(): await self._lock_authorized_context(request) retained, _, durable = await runtime.durable_put.admit_in_transaction( SubmissionBundleDurablePutRequest( prepared_authorization=None, prepared_artifact=prepared, - pass_capability=evidence.pass_capability, - replay_durable_intent_id=replay_intent_id, + selected_completion=completion, ) ) result = await runtime.durable_put.publish_after_commit( retained, - evidence.evidence.evidence_set_id, + reservation.attempt_id, durable, ) return self._result(result) - except PreSubmitEvidenceConflict as exc: - raise self._evidence_failure(exc) from exc + except SelectedPreSubmitRequestConflict as exc: + raise SubmissionBundlePreparationRejected( + "submission_bundle_preparation_request_conflict" + ) from exc except ArtifactAuthorityDeniedError as exc: raise SubmissionBundlePreparationUnavailable( "submission bundle preparation is unavailable" @@ -611,96 +727,78 @@ async def prepare( finally: self._authority.close() - async def _matching_replay_intent(self, evidence_id: UUID) -> UUID | None: - """Find an older exact lineage without trusting client replay selectors.""" - async with self._session.begin(): - current = await self._session.get(PreSubmitEvidenceSet, str(evidence_id)) - if current is None: - raise SubmissionBundlePreparationRejected( - "pre_submission_checked_custody_unavailable" - ) - fields = ( - "actor_profile_id", - "identity_link_id", - "project_id", - "task_id", - "assignment_id", - "predecessor_submission_id", - "predecessor_submission_version", - "checker_remediation_manifest_id", - "archive_sha256", - "archive_byte_count", - "semantic_manifest_sha256", - "guide_id", - "guide_version", - "source_snapshot_id", - "source_snapshot_sha256", - "locked_guide_sha256", - "effective_policy_id", - "locked_artifact_policy_sha256", - "pre_submit_policy_id", - "locked_checker_policy_sha256", - "effective_plan_sha256", - "catalogue_id", - "catalogue_version", - "catalogue_manifest_sha256", - "storage_scheme", - "terminal_status", - "eligible", - "result_count", - "result_manifest_sha256", + async def _selected_replay_result( + self, current: SelectedPreSubmitCurrentFacts + ) -> SubmissionBundlePreparationResult: + """Replay exact work facts or an already durable pass without execution.""" + terminal = current.terminal + if current.status in {"queued", "running"} or terminal is None: + raise SubmissionBundlePreparationInfrastructureUnavailable( + "pre_submission_attempt_outcome_unresolved" ) - statement = ( - select(SubmissionBundleDurableIntent.id) - .join( - PreSubmitEvidenceSet, - PreSubmitEvidenceSet.id - == SubmissionBundleDurableIntent.pre_submit_evidence_set_id, + if terminal.outcome == "infrastructure_failed": + raise SubmissionBundlePreparationInfrastructureUnavailable( + terminal.infrastructure_failure_code or "pre_submission_checked_custody_unavailable" + ) + execution = terminal.execution + if execution is None: + raise SubmissionBundlePreparationInfrastructureUnavailable( + "pre_submission_checked_custody_unavailable" + ) + if not execution.eligible: + raise SubmissionBundlePreparationCheckFailed(execution) + row = ( + await self._session.execute( + select( + SubmissionBundleDurableIntent, + ArtifactPutAttempt, + SubmissionBundleAdmission, ) .join( ArtifactPutAttempt, ArtifactPutAttempt.id == SubmissionBundleDurableIntent.put_attempt_id, ) + .outerjoin( + SubmissionBundleAdmission, + SubmissionBundleAdmission.durable_intent_id == SubmissionBundleDurableIntent.id, + ) .where( - PreSubmitEvidenceSet.id != current.id, - ArtifactPutAttempt.status.in_( - ( - "prepared", - "acknowledgement_unknown", - "absent_replay_required", - "object_confirmed", - ) - ), - *( - getattr(PreSubmitEvidenceSet, field) == getattr(current, field) - for field in fields - ), + SubmissionBundleDurableIntent.selected_pre_submit_run_id == str(current.run_id) ) - .order_by(SubmissionBundleDurableIntent.created_at) - .limit(1) ) - value = await self._session.scalar(statement) - return UUID(value) if value is not None else None + ).one_or_none() + if row is None: + raise SubmissionBundlePreparationInfrastructureUnavailable( + "pre_submission_checked_custody_unavailable" + ) + _, attempt, admission = row + status = attempt.status + admission_id = None + if admission is not None: + if admission.status == "ready": + status, admission_id = "ready", UUID(admission.id) + elif admission.status == "stale": + status = "stale" + else: + status = "conflict" + return SubmissionBundlePreparationResult( + put_attempt_id=UUID(attempt.id), + admission_id=admission_id, + submission_bundle_preparation_status=SubmissionBundlePreparationStatus(status), + replayed=True, + ) + - @staticmethod - def _evidence_failure(exc: PreSubmitEvidenceConflict) -> RuntimeError: - """Separate changed client context from unavailable canonical execution evidence.""" - if str(exc) == "pre_submit_locked_context_changed": - return SubmissionBundlePreparationRejected("submission_bundle_preparation_context_changed") - if str(exc) == "pre_submit_attempt_request_conflict": - return SubmissionBundlePreparationRejected("submission_bundle_preparation_request_conflict") - code = ("pre_submission_attempt_outcome_unresolved" - if str(exc) == "pre_submit_attempt_outcome_unresolved" - else "pre_submission_checked_custody_unavailable") - return SubmissionBundlePreparationInfrastructureUnavailable(code) async def _lock_authorized_context( - self, request: SubmissionBundlePreparationRequest, + self, + request: SubmissionBundlePreparationRequest, ) -> tuple[TaskSubmissionContextFacts, ProjectLockedPolicyContextFacts]: """Lock TASK, actor identity, PROJECT, then the current submitter grant.""" task, project = await self._lock_context(request) await self._authority.revalidate( - request=request, project_id=task.locked_project_context.project_id, + request=request, + project_id=task.locked_project_context.project_id, ) return task, project @@ -738,92 +836,6 @@ async def _lock_context( ) from exc return task_context, project_context - @staticmethod - def _compile_plan( - task_context: TaskSubmissionContextFacts, - project_context: ProjectLockedPolicyContextFacts, - planner: EffectivePreSubmissionPlanningPort, - ) -> EffectivePreSubmissionExecutionPlan: - """Compile the sole CHECKER plan from exact public PROJECT facts.""" - try: - effective_policy = json.loads(project_context.effective_policy.value) - compiled_bundle = json.loads( - project_context.compiled_pre_submit_bundle.value - ) - except (TypeError, ValueError) as exc: - raise SubmissionBundlePreparationRejected( - "submission_bundle_preparation_context_changed" - ) from exc - if ( - project_context.project_id - != task_context.locked_project_context.project_id - or not isinstance(effective_policy, dict) - or not isinstance(compiled_bundle, dict) - ): - raise SubmissionBundlePreparationRejected( - "submission_bundle_preparation_context_changed" - ) - return planner.compile_effective_plan( - lineage=EffectivePreSubmissionPlanLineage( - project_id=project_context.project_id, - guide_id=project_context.guide_id, - guide_version=project_context.guide_version, - source_snapshot_id=project_context.source_snapshot_id, - source_snapshot_hash=project_context.source_snapshot_hash, - effective_policy_id=project_context.effective_policy_id, - effective_policy_hash=project_context.effective_policy_hash, - pre_submit_policy_id=project_context.pre_submit_policy_id, - pre_submit_policy_bundle_hash=( - project_context.pre_submit_policy_bundle_hash - ), - ), - effective_policy=effective_policy, - compiled_bundle=compiled_bundle, - ) - - async def _existing_durable_result( - self, evidence_id: UUID - ) -> SubmissionBundlePreparationResult | None: - async with self._session.begin(): - row = ( - await self._session.execute( - select( - SubmissionBundleDurableIntent, - ArtifactPutAttempt, - SubmissionBundleAdmission, - ) - .join( - ArtifactPutAttempt, - ArtifactPutAttempt.id == SubmissionBundleDurableIntent.put_attempt_id, - ) - .outerjoin( - SubmissionBundleAdmission, - SubmissionBundleAdmission.durable_intent_id - == SubmissionBundleDurableIntent.id, - ) - .where( - SubmissionBundleDurableIntent.pre_submit_evidence_set_id == str(evidence_id) - ) - ) - ).one_or_none() - if row is None: - return None - _, attempt, admission = row - status = attempt.status - admission_id = None - if admission is not None: - if admission.status == "ready": - status, admission_id = "ready", UUID(admission.id) - elif admission.status == "stale": - status = "stale" - else: - status = "conflict" - return SubmissionBundlePreparationResult( - put_attempt_id=UUID(attempt.id), - admission_id=admission_id, - submission_bundle_preparation_status=SubmissionBundlePreparationStatus(status), - replayed=True, - ) async def _load_predecessor( self, @@ -850,13 +862,37 @@ async def _load_predecessor( semantic_manifest_sha256=admission.semantic_manifest_sha256, ) + async def _load_predecessor_rules( + self, + task_context: TaskSubmissionContextFacts, + ) -> str | None: + """Require the predecessor's exact selected rules commitment.""" + predecessor = task_context.predecessor + if predecessor is None: + return None + admission = await self._session.scalar( + select(SubmissionBundleAdmission).where( + SubmissionBundleAdmission.consumed_by_submission_id + == str(predecessor.submission_id), + SubmissionBundleAdmission.status == "consumed", + ) + ) + if ( + admission is None + or admission.selected_pre_submit_run_id is None + or admission.pre_submit_evidence_set_id is not None + or admission.selected_rules_sha256 is None + ): + raise SubmissionBundlePreparationRejected( + "submission_canonical_predecessor_unavailable" + ) + return admission.selected_rules_sha256 + @staticmethod def _result(result: SubmissionBundleDurablePutResult) -> SubmissionBundlePreparationResult: return SubmissionBundlePreparationResult( put_attempt_id=result.put_attempt_id, admission_id=result.admission_id, - submission_bundle_preparation_status=SubmissionBundlePreparationStatus( - result.status - ), + submission_bundle_preparation_status=SubmissionBundlePreparationStatus(result.status), replayed=result.replayed, ) diff --git a/backend/app/modules/artifacts/submission_bindings.py b/backend/app/modules/artifacts/submission_bindings.py index 5b715c35a..233ca0257 100644 --- a/backend/app/modules/artifacts/submission_bindings.py +++ b/backend/app/modules/artifacts/submission_bindings.py @@ -19,10 +19,10 @@ ) from app.modules.artifacts.api.submission_admission import ConsumedSubmissionAdmissionRequest from app.modules.artifacts.submission_manifest import SubmissionManifest +from app.modules.checkers.api.external_policy import validate_stored_external_post_policy from app.modules.artifacts.models import ( ArtifactBinding, ArtifactContent, - PreSubmitEvidenceSet, SubmissionBundleAdmission, SubmissionBindingReceipt, ) @@ -35,7 +35,8 @@ class SubmissionBindingAuthorityFacts: """Exact ART-owned facts consumed before binding and terminal mutation.""" admission_id: UUID - evidence_set_id: UUID + evidence_set_id: UUID | None + selected_pre_submit_run_id: UUID | None actor_profile_id: UUID identity_link_id: UUID project_id: UUID @@ -122,41 +123,37 @@ async def consume( raise SubmissionAdmissionConsumptionError( "submission_bundle_admission_already_consumed" ) - evidence = await self._session.scalar( - select(PreSubmitEvidenceSet) - .where(PreSubmitEvidenceSet.id == admission.pre_submit_evidence_set_id) - .with_for_update() - ) content = await self._session.scalar( select(ArtifactContent) .where(ArtifactContent.id == admission.artifact_content_id) .with_for_update() ) - if evidence is None or content is None: + if content is None: raise SubmissionAdmissionConsumptionError( "submission_bundle_admission_unavailable" ) - if not self._art_lineage_is_intact(admission, evidence, content): + if not self._art_lineage_is_intact(admission, content): raise SubmissionAdmissionConsumptionError( "submission_bundle_admission_unavailable" ) try: SubmissionManifest.from_dict( - evidence.semantic_manifest_body, sha256=evidence.semantic_manifest_sha256, + admission.semantic_manifest_body, + sha256=admission.semantic_manifest_sha256, ) except (TypeError, ValueError) as exc: raise SubmissionAdmissionConsumptionError("submission_bundle_admission_unavailable") from exc - if evidence.packet_sha256 != request.packet_sha256: + if admission.packet_sha256 != request.packet_sha256: raise SubmissionAdmissionConsumptionError("submission_bundle_admission_unavailable") if admission.status == "consumed": raise SubmissionAdmissionConsumptionError("submission_bundle_admission_already_consumed") - if not self._task_lineage_matches(admission, evidence, request): + if not self._task_lineage_matches(admission, request): now = await self._session.scalar(select(func.now())) admission.status = "stale" admission.stale_at = now admission.stale_reason = "locked_submission_context_changed" await self._session.flush() - return self._result(admission, request, evidence, binding=None, replayed=False) + return self._result(admission, request, binding=None, replayed=False) await self._lock_binding_scope(admission, request) existing = await self._session.scalar( @@ -179,13 +176,12 @@ async def consume( return self._result( admission, request, - evidence, binding=None, replayed=False, ) binding_decision_id = await self._authorization.consume( - self._authority_facts(admission, evidence, request) + self._authority_facts(admission, request) ) binding = ArtifactBinding( @@ -211,7 +207,7 @@ async def consume( admission_id=str(request.admission_id), binding_id=binding.id, decision_id=str(binding_decision_id), )) await self._session.flush() - return self._result(admission, request, evidence, binding=binding, replayed=False, + return self._result(admission, request, binding=binding, replayed=False, binding_decision_id=binding_decision_id) async def _lock_binding_scope( @@ -257,9 +253,6 @@ async def read_consumed( populate_existing=True) if receipt is None: raise SubmissionAdmissionConsumptionError("submission_bundle_admission_unavailable") - evidence = await self._session.scalar(select(PreSubmitEvidenceSet).where( - PreSubmitEvidenceSet.id == admission.pre_submit_evidence_set_id, - ).with_for_update().execution_options(populate_existing=True)) content = await self._session.scalar(select(ArtifactContent).where( ArtifactContent.id == admission.artifact_content_id, ).with_for_update().execution_options(populate_existing=True)) @@ -271,47 +264,31 @@ async def read_consumed( ArtifactBinding.scope_version == 1, ).execution_options(populate_existing=True)) if ( - evidence is None or content is None or binding is None + content is None or binding is None or binding.content_id != admission.artifact_content_id or binding.id != receipt.binding_id - or not self._art_lineage_is_intact(admission, evidence, content) - or evidence.packet_sha256 != request.packet_sha256 + or not self._art_lineage_is_intact(admission, content) + or admission.packet_sha256 != request.packet_sha256 ): raise SubmissionAdmissionConsumptionError("submission_bundle_admission_context_changed") await self._authorization.validate_replay( - self._authority_facts(admission, evidence, request), UUID(receipt.decision_id), + self._authority_facts(admission, request), UUID(receipt.decision_id), ) - return self._result(admission, request, evidence, binding=binding, replayed=True, + return self._result(admission, request, binding=binding, replayed=True, binding_decision_id=UUID(receipt.decision_id)) @staticmethod def _art_lineage_is_intact( - admission: SubmissionBundleAdmission, - evidence: PreSubmitEvidenceSet, - content: ArtifactContent, + admission: SubmissionBundleAdmission, content: ArtifactContent, ) -> bool: + try: + policy = validate_stored_external_post_policy(admission.selected_post_submit_policy_body) + except (TypeError, ValueError): + return False return bool( - evidence.id == admission.pre_submit_evidence_set_id - and evidence.actor_profile_id == admission.actor_profile_id - and evidence.identity_link_id == admission.identity_link_id - and evidence.project_id == admission.project_id - and evidence.task_id == admission.task_id - and evidence.assignment_id == admission.assignment_id - and evidence.predecessor_submission_id - == admission.predecessor_submission_id - and evidence.predecessor_submission_version - == admission.predecessor_submission_version - and evidence.checker_remediation_manifest_id - == admission.checker_remediation_manifest_id - and evidence.locked_policy_context_hash - == admission.locked_policy_context_hash - and evidence.semantic_manifest_id == admission.semantic_manifest_id - and evidence.semantic_manifest_sha256 - == admission.semantic_manifest_sha256 - and evidence.archive_sha256 == admission.archive_sha256 - and evidence.archive_byte_count == admission.archive_byte_count - and evidence.terminal_status == "passed" - and evidence.eligible is True + admission.pre_submit_evidence_set_id is None + and admission.selected_pre_submit_run_id is not None + and policy.policy_sha256 == admission.selected_post_submit_policy_sha256 and content.id == admission.artifact_content_id and content.sha256 == admission.archive_sha256 and content.byte_count == admission.archive_byte_count @@ -320,7 +297,6 @@ def _art_lineage_is_intact( @staticmethod def _task_lineage_matches( admission: SubmissionBundleAdmission, - evidence: PreSubmitEvidenceSet, request: SubmissionAdmissionConsumptionRequest, ) -> bool: context = request.task_context @@ -337,25 +313,25 @@ def _task_lineage_matches( == (predecessor.version if predecessor is not None else None) and admission.checker_remediation_manifest_id == context.checker_remediation_manifest_id - and evidence.guide_version == references.guide_version - and evidence.source_snapshot_id == str(references.source_snapshot_id) - and evidence.source_snapshot_sha256 == references.source_snapshot_hash - and evidence.effective_policy_id == str(references.effective_policy_id) - and evidence.locked_artifact_policy_sha256 == references.effective_policy_hash - and evidence.pre_submit_policy_id == str(references.pre_submit_policy_id) - and evidence.locked_checker_policy_sha256 + and admission.guide_version == references.guide_version + and admission.source_snapshot_id == str(references.source_snapshot_id) + and admission.source_snapshot_sha256 == references.source_snapshot_hash + and admission.effective_policy_id == str(references.effective_policy_id) + and admission.effective_policy_sha256 == references.effective_policy_hash + and admission.pre_submit_policy_id == str(references.pre_submit_policy_id) + and admission.pre_submit_policy_sha256 == references.pre_submit_policy_bundle_hash ) @staticmethod def _authority_facts( admission: SubmissionBundleAdmission, - evidence: PreSubmitEvidenceSet, request: SubmissionAdmissionConsumptionRequest | ConsumedSubmissionAdmissionRequest, ) -> SubmissionBindingAuthorityFacts: return SubmissionBindingAuthorityFacts( admission_id=request.admission_id, - evidence_set_id=UUID(evidence.id), + evidence_set_id=None, + selected_pre_submit_run_id=UUID(admission.selected_pre_submit_run_id), actor_profile_id=UUID(admission.actor_profile_id), identity_link_id=UUID(admission.identity_link_id), project_id=UUID(admission.project_id), @@ -370,17 +346,17 @@ def _authority_facts( checker_remediation_manifest_id=admission.checker_remediation_manifest_id, submission_id=request.submission_id, submission_version=request.submission_version, - guide_id=UUID(evidence.guide_id), - guide_version=evidence.guide_version, - source_snapshot_id=UUID(evidence.source_snapshot_id), - source_snapshot_sha256=evidence.source_snapshot_sha256, - effective_policy_id=UUID(evidence.effective_policy_id), - effective_policy_sha256=evidence.locked_artifact_policy_sha256, - pre_submit_policy_id=UUID(evidence.pre_submit_policy_id), - pre_submit_policy_sha256=evidence.locked_checker_policy_sha256, - locked_policy_context_hash=evidence.locked_policy_context_hash, - semantic_manifest_id=UUID(evidence.semantic_manifest_id), - semantic_manifest_sha256=evidence.semantic_manifest_sha256, + guide_id=UUID(admission.guide_id), + guide_version=admission.guide_version, + source_snapshot_id=UUID(admission.source_snapshot_id), + source_snapshot_sha256=admission.source_snapshot_sha256, + effective_policy_id=UUID(admission.effective_policy_id), + effective_policy_sha256=admission.effective_policy_sha256, + pre_submit_policy_id=UUID(admission.pre_submit_policy_id), + pre_submit_policy_sha256=admission.pre_submit_policy_sha256, + locked_policy_context_hash=admission.locked_policy_context_hash, + semantic_manifest_id=UUID(admission.semantic_manifest_id), + semantic_manifest_sha256=admission.semantic_manifest_sha256, content_id=UUID(admission.artifact_content_id), sha256=admission.archive_sha256, byte_count=admission.archive_byte_count, @@ -391,7 +367,6 @@ def _authority_facts( def _result( admission: SubmissionBundleAdmission, request: SubmissionAdmissionConsumptionRequest | ConsumedSubmissionAdmissionRequest, - evidence: PreSubmitEvidenceSet, *, binding: ArtifactBinding | None, replayed: bool, @@ -400,7 +375,8 @@ def _result( material = None if admission.status == "consumed": manifest = SubmissionManifest.from_dict( - evidence.semantic_manifest_body, sha256=evidence.semantic_manifest_sha256, + admission.semantic_manifest_body, + sha256=admission.semantic_manifest_sha256, ) material = SubmissionAdmissionMaterial( archive_sha256=admission.archive_sha256, @@ -418,4 +394,10 @@ def _result( replayed=replayed, material=material, binding_decision_id=binding_decision_id, + selected_post_submit_policy_sha256=( + admission.selected_post_submit_policy_sha256 + ), + selected_post_submit_policy_body=( + admission.selected_post_submit_policy_body + ), ) diff --git a/backend/app/modules/artifacts/submission_custody.py b/backend/app/modules/artifacts/submission_custody.py index 11c585105..2efc8f28c 100644 --- a/backend/app/modules/artifacts/submission_custody.py +++ b/backend/app/modules/artifacts/submission_custody.py @@ -5,47 +5,89 @@ from typing import final from uuid import UUID -from app.modules.artifacts.pre_submit_evidence import PreSubmitPassCapability from app.modules.artifacts.sources import CommittedArtifactSource, PreparedArtifact +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitCompletion, + SelectedPreSubmitReservationFacts, +) @final class SubmissionBundlePreparedCustody: """Unforgeable join of live prepared bytes and their fresh pass capability.""" - __slots__ = ("_capability", "_prepared") + __slots__ = ("_prepared", "_selected") def __init__(self, *_: object, **__: object) -> None: raise TypeError("submission bundle custody requires live prepared work") + @classmethod - def _from_live_preparation( + def _from_selected_pre_submit( cls, *, prepared: PreparedArtifact, - capability: PreSubmitPassCapability, + completion: SelectedPreSubmitCompletion, ) -> SubmissionBundlePreparedCustody: + """Seal a completed selected run to the still-live verified bytes once.""" if ( type(prepared) is not PreparedArtifact - or type(capability) is not PreSubmitPassCapability + or type(completion) is not SelectedPreSubmitCompletion + or completion.capability is None + or completion.terminal.outcome != "completed" + or completion.terminal.execution is None + or not completion.terminal.execution.eligible + ): + raise TypeError("submission bundle selected custody requires a passing run") + reservation = completion.terminal.lease.reservation + if ( + type(reservation) is not SelectedPreSubmitReservationFacts + or reservation.admission_lineage is None + or reservation.prepared_generation_id != prepared.generation_id + or reservation.archive_sha256 != prepared.commitment.sha256 + or reservation.archive_byte_count != prepared.commitment.byte_count ): - raise TypeError("submission bundle custody requires live prepared work") - capability._assert_live_prepared_custody( - prepared_generation_id=prepared.generation_id, - archive_sha256=prepared.commitment.sha256, + raise TypeError("submission bundle selected custody differs from prepared bytes") + run_id = completion.capability.consume( + run_id=reservation.attempt_id, + terminal_digest=completion.terminal.terminal_digest, + attempt_id=reservation.attempt_id, + prepared_generation_id=reservation.prepared_generation_id, + archive_sha256=reservation.archive_sha256, + semantic_manifest_sha256=reservation.semantic_manifest_sha256, ) + if run_id != reservation.attempt_id: + raise TypeError("submission bundle selected custody is unavailable") custody = object.__new__(cls) custody._prepared = prepared - custody._capability = capability + custody._selected = completion.terminal return custody @property def prepared_generation_id(self) -> UUID: return self._prepared.generation_id + + @property + def selected_reservation(self) -> SelectedPreSubmitReservationFacts: + """Return the exact selected request only for selected-run custody.""" + if self._selected is None: + raise TypeError("selected submission custody is unavailable") + return self._selected.lease.reservation + + @property + def selected_terminal_digest(self) -> str: + """Return the terminal digest paired with the selected reservation.""" + if self._selected is None: + raise TypeError("selected submission custody is unavailable") + return self._selected.terminal_digest + @property - def pass_capability(self) -> PreSubmitPassCapability: - return self._capability + def selected_terminal_body(self) -> dict[str, object]: + """Return a fresh canonical terminal body for the durable ART snapshot.""" + if self._selected is None: + raise TypeError("selected submission custody is unavailable") + return self._selected.canonical_body() @property def source(self) -> CommittedArtifactSource: diff --git a/backend/app/modules/artifacts/submission_manifest.py b/backend/app/modules/artifacts/submission_manifest.py index ae39956e4..2cc31d0a3 100644 --- a/backend/app/modules/artifacts/submission_manifest.py +++ b/backend/app/modules/artifacts/submission_manifest.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from uuid import UUID +from uuid import NAMESPACE_URL, UUID, uuid5 from app.core.hashing import canonical_json_hash from app.modules.artifacts.api.submission_admission import SubmissionBundleFile @@ -22,6 +22,15 @@ SUBMISSION_MANIFEST_SCHEMA_VERSION = "workstream.submission_bundle_manifest.v1" +def semantic_manifest_identity(semantic_manifest_sha256: str) -> UUID: + """Derive one provider-neutral identity from the canonical manifest digest.""" + ArtifactCommitment.validate_sha256(semantic_manifest_sha256) + return uuid5( + NAMESPACE_URL, + f"workstream:submission-semantic-manifest:v1:{semantic_manifest_sha256}", + ) + + class SubmissionChangeFailureCode(StrEnum): """Stable side-effect-free semantic change-gate failures.""" @@ -44,6 +53,7 @@ class SubmissionChangeOutcome(StrEnum): FIRST_SUBMISSION = "first_submission" CHANGED = "changed" + UNCHANGED = "unchanged" @dataclass(frozen=True, slots=True) @@ -240,6 +250,33 @@ def evaluate_submission_change( current_predecessor: SubmissionCanonicalPredecessor | None = None, ) -> SubmissionChangeGateResult: """Compare server-owned identities without performing any durable effect.""" + result = bind_submission_change( + commitment=commitment, + manifest=manifest, + predecessor=predecessor, + predecessor_exists=predecessor_exists, + current_predecessor=current_predecessor, + ) + if result.outcome is SubmissionChangeOutcome.UNCHANGED: + assert predecessor is not None + code = ( + SubmissionChangeFailureCode.ARCHIVE_UNCHANGED + if commitment.sha256 == predecessor.archive_sha256 + else SubmissionChangeFailureCode.MANIFEST_UNCHANGED + ) + raise SubmissionChangeRejectedError(code) + return result + + +def bind_submission_change( + *, + commitment: ArtifactCommitment, + manifest: SubmissionManifest, + predecessor: SubmissionCanonicalPredecessor | None, + predecessor_exists: bool, + current_predecessor: SubmissionCanonicalPredecessor | None = None, +) -> SubmissionChangeGateResult: + """Bind exact predecessor and byte facts without deciding the default check.""" if predecessor is not None and not predecessor_exists: raise ValueError("submission predecessor existence is inconsistent") if predecessor is None: @@ -256,14 +293,18 @@ def evaluate_submission_change( ) if current_predecessor is None or current_predecessor != predecessor: raise SubmissionChangeRejectedError(SubmissionChangeFailureCode.PREDECESSOR_STALE) - if commitment.sha256 == predecessor.archive_sha256: - raise SubmissionChangeRejectedError(SubmissionChangeFailureCode.ARCHIVE_UNCHANGED) - if manifest.sha256 == predecessor.semantic_manifest_sha256: - raise SubmissionChangeRejectedError(SubmissionChangeFailureCode.MANIFEST_UNCHANGED) + unchanged = ( + commitment.sha256 == predecessor.archive_sha256 + or manifest.sha256 == predecessor.semantic_manifest_sha256 + ) return SubmissionChangeGateResult( commitment.sha256, commitment.byte_count, manifest, - SubmissionChangeOutcome.CHANGED, + ( + SubmissionChangeOutcome.UNCHANGED + if unchanged + else SubmissionChangeOutcome.CHANGED + ), predecessor, ) diff --git a/backend/app/modules/artifacts/submission_materialization.py b/backend/app/modules/artifacts/submission_materialization.py index 22f54fd1b..3e27546a6 100644 --- a/backend/app/modules/artifacts/submission_materialization.py +++ b/backend/app/modules/artifacts/submission_materialization.py @@ -2,30 +2,25 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, field, replace +from contextlib import AbstractContextManager, contextmanager +from dataclasses import asdict, dataclass, field +from pathlib import Path from typing import Protocol, final from uuid import UUID -from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.core.hashing import canonical_json_hash -from app.modules.artifacts.api import SubmissionBundlePreparationRequest +from app.interfaces.external_checker_execution import ( + ExternalCheckerMaterialGrant, +) from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError -from app.modules.artifacts.preparation import ArtifactPreparationService -from app.modules.artifacts.sources import PreparedArtifact -from app.modules.artifacts.pre_submit_evidence import ( - PreSubmitEvidenceInput, - PreSubmitEvidenceContext, - PreSubmitEvidenceConflict, - PreSubmitExecutionCustody, - PreSubmitExecutionResult, - PreSubmitEvidencePersistenceRequest, - PreSubmitEvidencePersistenceResult, - PreSubmitEvidenceService, +from app.modules.artifacts.preparation import ( + ArtifactPreparationService, + ExternalMaterialFile, + ExternalMaterialGrantRequest, ) -from app.modules.artifacts.pre_submit_attempts import PreSubmitAttemptClaim, PreSubmitAttemptStore -from app.modules.artifacts.models import PreSubmitExecutionAttempt +from app.modules.artifacts.sources import PreparedArtifact from app.modules.artifacts.submission_archive import ( SubmissionArchiveInspectionResult, ) @@ -34,18 +29,31 @@ SubmissionManifest, build_submission_manifest, ) -from app.modules.artifacts.submission_authorization import ( - SubmissionBundlePreparationAuthorization, -) -from app.modules.checkers.api import ( +from app.modules.checkers.api.pre_submit_material import ( ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, - EffectivePreSubmissionExecutionPlan, - PreSubmissionExecutionFacts, - PreSubmissionInfrastructureUnavailableError, SubmissionPacketView, ) -from app.modules.projects.api import ProjectLockedPolicyContextPort -from app.modules.tasks.api import TaskSubmissionContextPort +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + WorkstreamDefaultCheckerInput, + workstream_default_input_body, +) +from app.modules.checkers.api.external import ( + ExternalCheckerExecutionRequest, + PreSubmitExternalCheckerIdentity, + PreSubmitExternalCheckerMaterial, +) +from app.modules.checkers.api.external_policy import LockedExternalPreSubmitPolicy +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitExecutionAuthority, + SelectedPreSubmitExecutionFacts, + SelectedPreSubmitLeaseFacts, + SelectedPreSubmitReservationFacts, + SelectedPreSubmitInfrastructureUnavailable, +) +from app.modules.projects.api import ProjectLockedPolicyContextFacts +from app.modules.tasks.api import TaskSubmissionContextFacts + @dataclass(frozen=True, slots=True) class PreparedBundleMaterializationRequest: @@ -59,18 +67,22 @@ class PreparedBundleMaterializationRequest: predecessor_submission_version: int | None checker_remediation_manifest_id: UUID | None = field(default=None, kw_only=True) prepared_artifact: PreparedArtifact - effective_plan: EffectivePreSubmissionExecutionPlan inspection: SubmissionArchiveInspectionResult manifest: SubmissionManifest change_gate: SubmissionChangeGateResult packet: SubmissionPacketView + external_policy: LockedExternalPreSubmitPolicy + default_checker_input: WorkstreamDefaultCheckerInput + selected_reservation: SelectedPreSubmitReservationFacts @dataclass(frozen=True, slots=True) class PreSubmitCheckerExecutionRequest: """Exact process-local input supplied to the composition CHECKER adapter.""" - plan: EffectivePreSubmissionExecutionPlan + project_id: UUID + task_id: UUID + assignment_id: UUID commitment: object inspection: SubmissionArchiveInspectionResult manifest: SubmissionManifest @@ -78,15 +90,115 @@ class PreSubmitCheckerExecutionRequest: packet: SubmissionPacketView prepared_generation_id: UUID storage_scheme: str + external_policy: LockedExternalPreSubmitPolicy + default_checker_input: WorkstreamDefaultCheckerInput + attempt_id: UUID + attempt_request_digest: str + external_grants: "PreSubmitExternalGrantPort" + + +class PreSubmitExternalGrantPort(Protocol): + """Issue one callback-scoped grant from genuine prepared ART custody.""" + def issue( + self, + request: ExternalCheckerExecutionRequest, + workspace: Path, + ) -> AbstractContextManager[ExternalCheckerMaterialGrant]: ... + + +@dataclass(frozen=True, slots=True) +class _SelectedGrantClaim: + """Minimum genuine selected-run identity required by the ART grant issuer.""" -class PreSubmissionEvaluationPort(Protocol): - """Finish ART's committed selection through the composed checker phase service.""" + attempt_id: UUID + request_digest: str - async def evaluate_pre_submission( - self, request: PreparedBundleMaterializationRequest, reservation: object, *, - preparation_request: SubmissionBundlePreparationRequest, - ) -> PreSubmitEvidencePersistenceResult: ... + +class _PreSubmitExternalGrantIssuer: + """Keep private prepared-artifact authority behind a typed callback port.""" + + def __init__( + self, + *, + preparation: ArtifactPreparationService, + prepared: PreparedArtifact, + request: PreparedBundleMaterializationRequest, + claim: _SelectedGrantClaim, + ) -> None: + self._preparation = preparation + self._prepared = prepared + self._request = request + self._claim = claim + + @contextmanager + def issue( + self, + request: ExternalCheckerExecutionRequest, + workspace: Path, + ): + """Bind the service grant to exact claim, manifest and prepared bytes.""" + checked = ExternalCheckerExecutionRequest.model_validate(request) + identity = checked.identity + source = self._request + project_id = source.selected_reservation.project_id + if ( + not isinstance(identity, PreSubmitExternalCheckerIdentity) + or identity.project_id != project_id + or identity.task_id != source.task_id + or identity.assignment_id != source.assignment_id + or identity.prepared_generation_id != source.prepared_artifact.generation_id + or identity.attempt_id != self._claim.attempt_id + or identity.attempt_request_digest != self._claim.request_digest + or len(checked.materials) != 1 + or not isinstance(checked.materials[0], PreSubmitExternalCheckerMaterial) + ): + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") + material = checked.materials[0] + commitment = source.prepared_artifact.commitment + if ( + material.prepared_generation_id != source.prepared_artifact.generation_id + or material.attempt_id != self._claim.attempt_id + or material.attempt_request_digest != self._claim.request_digest + or material.archive_sha256 != commitment.sha256 + or material.archive_byte_count != commitment.byte_count + or material.semantic_manifest_sha256 != source.manifest.sha256 + or material.media_type != commitment.media_type + ): + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") + directories = tuple( + item.normalized_path + for item in source.manifest.entries + if item.entry_type.value == "directory" + ) + files = tuple( + ExternalMaterialFile( + normalized_path=item.normalized_path, + byte_count=item.byte_count, + sha256=item.sha256 or "", + executable=bool(item.executable), + ) + for item in source.manifest.entries + if item.entry_type.value == "file" + ) + grant_request = ExternalMaterialGrantRequest( + request_digest=checked.request_digest, + prepared_generation_id=str(identity.prepared_generation_id), + attempt_id=str(identity.attempt_id), + attempt_request_digest=identity.attempt_request_digest, + archive_sha256=material.archive_sha256, + archive_byte_count=material.archive_byte_count, + semantic_manifest_sha256=material.semantic_manifest_sha256, + directories=directories, + files=files, + ) + with self._preparation.external_material_grant( + self._prepared, workspace, grant_request + ) as grant: + yield ExternalCheckerMaterialGrant( + grant_id=grant.grant_id, + binding_digest=grant.binding_digest, + ) class PreSubmitCheckerProcessor(Protocol): @@ -94,17 +206,12 @@ class PreSubmitCheckerProcessor(Protocol): def abort(self) -> None: ... - async def process( - self, reader: object, workspace: object - ) -> PreSubmissionExecutionFacts: ... + async def process(self, reader: object, workspace: object) -> SelectedPreSubmitExecutionFacts: ... class PreSubmitCheckerExecutionFactory(Protocol): """Build one process-local CHECKER processor without private imports in ART.""" - @property - def catalogue_manifest_sha256(self) -> str: ... - def build(self, request: PreSubmitCheckerExecutionRequest) -> PreSubmitCheckerProcessor: ... @@ -233,53 +340,73 @@ def __init__( raise ValueError("pre-submit materializer storage scheme is invalid") self._storage_scheme = storage_scheme - async def materialize_prepared_bundle( + + async def materialize_selected_pre_submit( self, request: PreparedBundleMaterializationRequest, *, - claim: PreSubmitAttemptClaim, - ) -> PreSubmitExecutionResult: - """Consume fixed-service authority before any byte or workspace access.""" - if type(claim) is not PreSubmitAttemptClaim: - raise PreSubmitEvidenceConflict("pre_submit_attempt_claim_invalid") - facts = self._authority_facts(request) - await self._authorization.consume( - prepared_authorization=request.prepared_authorization, - facts=facts, - ) - await claim.consume(self._session, request) + run_id: UUID, + lease: SelectedPreSubmitLeaseFacts, + execution_authority: SelectedPreSubmitExecutionAuthority, + ) -> SelectedPreSubmitExecutionFacts: + """Execute the committed external policy lease against genuine prepared bytes.""" + if self._session.in_transaction(): + raise RuntimeError("selected pre-submit execution requires no transaction") + checked = execution_authority.consume(run_id=run_id, lease=lease) + reservation = checked.reservation + commitment = request.prepared_artifact.commitment + if ( + run_id != reservation.attempt_id + or request.task_id != reservation.task_id + or request.assignment_id != reservation.assignment_id + or request.selected_reservation != reservation + or request.prepared_artifact.generation_id != reservation.prepared_generation_id + or request.external_policy is None + or request.external_policy.policy_sha256 != reservation.policy_sha256 + or request.default_checker_input is None + or canonical_json_hash(workstream_default_input_body(request.default_checker_input)) + != reservation.default_input_sha256 + or commitment.sha256 != reservation.archive_sha256 + or commitment.byte_count != reservation.archive_byte_count + or request.manifest.sha256 != reservation.semantic_manifest_sha256 + ): + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") processor = self._checker_execution.build( PreSubmitCheckerExecutionRequest( - plan=request.effective_plan, - commitment=request.prepared_artifact.commitment, + project_id=reservation.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + commitment=commitment, inspection=request.inspection, manifest=request.manifest, change_gate=request.change_gate, packet=request.packet, prepared_generation_id=request.prepared_artifact.generation_id, storage_scheme=self._storage_scheme, - ), + external_policy=request.external_policy, + default_checker_input=request.default_checker_input, + attempt_id=run_id, + attempt_request_digest=reservation.request_digest, + external_grants=_PreSubmitExternalGrantIssuer( + preparation=self._preparation, + prepared=request.prepared_artifact, + request=request, + claim=_SelectedGrantClaim( + attempt_id=run_id, + request_digest=reservation.request_digest, + ), + ), + ) ) - # Intentional friend call: this is the sole authority-gated caller, and - # the preparation byte-access surface must remain private. - checker_facts = await self._preparation._process_prepared_submission( + result = await self._preparation._process_prepared_submission( request.prepared_artifact, processor, reserved_bytes=request.manifest.total_expanded_bytes, - maximum_entries=request.manifest.entry_count, + maximum_entries=request.manifest.entry_count + 1, ) - execution = PreSubmitExecutionResult( - custody=PreSubmitExecutionCustody( - prepared_generation_id=request.prepared_artifact.generation_id, - archive_sha256=request.prepared_artifact.commitment.sha256, - archive_byte_count=request.prepared_artifact.commitment.byte_count, - semantic_manifest_sha256=request.manifest.sha256, - storage_scheme=self._storage_scheme, - ), - checker_facts=checker_facts, - ) - claim.finish(execution) - return execution + if type(result) is not SelectedPreSubmitExecutionFacts: + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") + return result async def authorize_inspected(self, request: PreparedBundleMaterializationRequest) -> None: """Authorize inspected custody for reservation without invoking checker members.""" @@ -288,33 +415,56 @@ async def authorize_inspected(self, request: PreparedBundleMaterializationReques facts=self._authority_facts(request), ) - async def authorize_original_replay( - self, request: PreparedBundleMaterializationRequest, *, generation_id: UUID, key: UUID, - ) -> None: - """Check original stored service facts; this grants no retry-byte capability.""" - facts = replace(self._authority_facts(request), prepared_generation_id=generation_id) - handle = await self._authorization.prepare(facts=facts.preparation, idempotency_key=key) - await self._authorization.consume(prepared_authorization=handle, facts=facts) - async def prepare_authorization( + async def prepare_selected_authorization( self, *, - task_id: UUID, - assignment_id: UUID, - submission_artifact_policy_id: UUID, - checker_policy_id: UUID, + task: TaskSubmissionContextFacts, + project: ProjectLockedPolicyContextFacts, prepared_artifact: PreparedArtifact, - effective_plan: EffectivePreSubmissionExecutionPlan, + external_policy: LockedExternalPreSubmitPolicy, idempotency_key: UUID, ) -> object: - """Deny unavailable service authority before inspecting the ZIP.""" - facts = self._preparation_facts( - task_id=task_id, - assignment_id=assignment_id, - submission_artifact_policy_id=submission_artifact_policy_id, - checker_policy_id=checker_policy_id, - prepared_artifact=prepared_artifact, - effective_plan=effective_plan, + """Prepare selected material authority before inspecting contributor bytes.""" + references = task.locked_project_context + commitment = prepared_artifact.commitment + if ( + task.task_id is None + or task.assignment_id is None + or references.project_id != project.project_id + or references.guide_version != project.guide_version + or references.source_snapshot_id != project.source_snapshot_id + or references.source_snapshot_hash != project.source_snapshot_hash + or references.effective_policy_id != project.effective_policy_id + or references.effective_policy_hash != project.effective_policy_hash + or references.pre_submit_policy_id != project.pre_submit_policy_id + or references.pre_submit_policy_bundle_hash != project.pre_submit_policy_bundle_hash + or external_policy.policy_sha256 + != canonical_json_hash( + external_policy.model_dump(mode="json", exclude={"policy_sha256"}) + ) + or external_policy.workstream_default.behavior_sha256 + != WORKSTREAM_DEFAULT_BEHAVIOR_SHA256 + ): + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") + facts = PreSubmitMaterializationPreparationFacts( + task_id=task.task_id, + assignment_id=task.assignment_id, + project_id=project.project_id, + guide_id=project.guide_id, + guide_version=project.guide_version, + source_snapshot_id=project.source_snapshot_id, + source_snapshot_hash=project.source_snapshot_hash, + submission_artifact_policy_id=project.effective_policy_id, + submission_artifact_policy_hash=project.effective_policy_hash, + checker_policy_id=project.pre_submit_policy_id, + checker_policy_hash=project.pre_submit_policy_bundle_hash, + prepared_generation_id=prepared_artifact.generation_id, + plan_sha256=external_policy.policy_sha256, + catalogue_manifest_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + archive_sha256=commitment.sha256, + archive_byte_count=commitment.byte_count, + storage_scheme=self._storage_scheme, ) return await self._authorization.prepare( facts=facts, @@ -326,14 +476,14 @@ def _authority_facts( request: PreparedBundleMaterializationRequest, ) -> PreSubmitMaterializationAuthorityFacts: """Build final authority facts from the canonical inspected manifest.""" - plan = request.effective_plan preparation = self._preparation_facts( task_id=request.task_id, assignment_id=request.assignment_id, submission_artifact_policy_id=request.submission_artifact_policy_id, checker_policy_id=request.checker_policy_id, prepared_artifact=request.prepared_artifact, - effective_plan=plan, + external_policy=request.external_policy, + selected_reservation=request.selected_reservation, ) if ( request.manifest != request.change_gate.manifest @@ -342,9 +492,7 @@ def _authority_facts( or request.prepared_artifact.commitment.byte_count != request.change_gate.archive_byte_count ): - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_materialization_context_invalid" - ) + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") return PreSubmitMaterializationAuthorityFacts( **asdict(preparation), semantic_manifest_sha256=request.manifest.sha256, @@ -358,160 +506,41 @@ def _preparation_facts( submission_artifact_policy_id: UUID, checker_policy_id: UUID, prepared_artifact: PreparedArtifact, - effective_plan: EffectivePreSubmissionExecutionPlan, + external_policy: LockedExternalPreSubmitPolicy, + selected_reservation: SelectedPreSubmitReservationFacts, ) -> PreSubmitMaterializationPreparationFacts: """Build pre-inspection facts from locked lineage and byte commitment.""" - plan = effective_plan - if plan.plan_sha256 != canonical_json_hash(plan.as_dict()): - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_plan_identity_invalid" - ) + lineage = selected_reservation.admission_lineage + commitment = prepared_artifact.commitment if ( - submission_artifact_policy_id != plan.lineage.effective_policy_id - or checker_policy_id != plan.lineage.pre_submit_policy_id - or plan.catalogue_manifest_sha256 - != self._checker_execution.catalogue_manifest_sha256 + lineage is None + or external_policy is None + or task_id != selected_reservation.task_id + or assignment_id != selected_reservation.assignment_id + or prepared_artifact.generation_id != selected_reservation.prepared_generation_id + or submission_artifact_policy_id != lineage.effective_policy_id + or checker_policy_id != lineage.pre_submit_policy_id + or external_policy.policy_sha256 != selected_reservation.policy_sha256 + or commitment.sha256 != selected_reservation.archive_sha256 + or commitment.byte_count != selected_reservation.archive_byte_count ): - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_materialization_context_invalid" - ) - commitment = prepared_artifact.commitment + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") return PreSubmitMaterializationPreparationFacts( task_id=task_id, assignment_id=assignment_id, - project_id=plan.lineage.project_id, - guide_id=plan.lineage.guide_id, - guide_version=plan.lineage.guide_version, - source_snapshot_id=plan.lineage.source_snapshot_id, - source_snapshot_hash=plan.lineage.source_snapshot_hash, + project_id=selected_reservation.project_id, + guide_id=lineage.guide_id, + guide_version=lineage.guide_version, + source_snapshot_id=lineage.source_snapshot_id, + source_snapshot_hash=lineage.source_snapshot_sha256, submission_artifact_policy_id=submission_artifact_policy_id, - submission_artifact_policy_hash=plan.lineage.effective_policy_hash, + submission_artifact_policy_hash=lineage.effective_policy_sha256, checker_policy_id=checker_policy_id, - checker_policy_hash=plan.lineage.pre_submit_policy_bundle_hash, + checker_policy_hash=lineage.pre_submit_policy_sha256, prepared_generation_id=prepared_artifact.generation_id, - plan_sha256=plan.plan_sha256, - catalogue_manifest_sha256=plan.catalogue_manifest_sha256, + plan_sha256=selected_reservation.policy_sha256, + catalogue_manifest_sha256=(selected_reservation.default_behavior_sha256), archive_sha256=commitment.sha256, archive_byte_count=commitment.byte_count, storage_scheme=self._storage_scheme, ) - - -class PreparedBundlePreSubmitEvidenceService: - """Execute in scratch, then persist exact evidence in a fresh transaction.""" - - def __init__( - self, - *, - session: AsyncSession, - materialization: PreparedBundleMaterializationService, - preparation_authorization: SubmissionBundlePreparationAuthorization, - task_contexts: TaskSubmissionContextPort, - project_contexts: ProjectLockedPolicyContextPort, - ) -> None: - """Bind execution to the transaction used for durable evidence.""" - self._session = session - self._materialization = materialization - self._preparation_authorization = preparation_authorization - self._task_contexts = task_contexts - self._project_contexts = project_contexts - self._attempts = PreSubmitAttemptStore(session) - - def _evidence_service(self) -> PreSubmitEvidenceService: - """Use the sole evidence owner for both lineage reads and completion.""" - return PreSubmitEvidenceService( - self._session, task_contexts=self._task_contexts, - project_contexts=self._project_contexts, - ) - - @staticmethod - def _input( - request: PreparedBundleMaterializationRequest, - preparation_request: SubmissionBundlePreparationRequest, - ) -> PreSubmitEvidenceInput: - if ( - request.task_id != preparation_request.task_id - or request.assignment_id != preparation_request.assignment_id - or request.packet.summary != preparation_request.summary - or request.packet.contributor_attestation != preparation_request.contributor_attestation - ): - raise PreSubmitEvidenceConflict("pre_submit_attempt_request_conflict") - commitment = request.prepared_artifact.commitment - return PreSubmitEvidenceInput( - actor_profile_id=preparation_request.actor.actor_profile_id, - identity_link_id=preparation_request.actor.identity_link_id, - task_id=request.task_id, assignment_id=request.assignment_id, - predecessor_submission_id=preparation_request.predecessor_submission_id, - expected_predecessor_submission_version=request.predecessor_submission_version, - expected_checker_remediation_manifest_id=request.checker_remediation_manifest_id, - prepared_generation_id=request.prepared_artifact.generation_id, - archive_sha256=commitment.sha256, archive_byte_count=commitment.byte_count, - semantic_manifest_sha256=request.manifest.sha256, plan=request.effective_plan, - ) - - async def _lock_authorized_context( - self, request: PreparedBundleMaterializationRequest, - preparation_request: SubmissionBundlePreparationRequest, - ) -> PreSubmitEvidenceContext: - """Match TASK, AUTH role issuance and revocation without reversing their locks.""" - evidence = self._evidence_service() - values = self._input(request, preparation_request) - task_context = await evidence.lock_task_context(values) - await self._preparation_authorization.lock_actor(request=preparation_request) - context = await evidence.lock_project_context( - values, task_context=task_context, - storage_scheme=self._materialization._storage_scheme, - ) - await self._preparation_authorization.revalidate(request=preparation_request) - return context - - async def reserve( - self, request: PreparedBundleMaterializationRequest, *, - preparation_request: SubmissionBundlePreparationRequest, - ) -> object: - """Consume inspected authority and reserve; the caller must commit before execution.""" - context = await self._lock_authorized_context(request, preparation_request) - await self._materialization.authorize_inspected(request) - selected = await self._attempts.reserve( - context=context, plan=request.effective_plan, packet=request.packet, - idempotency_key=preparation_request.idempotency_key, request=request, - ) - if isinstance(selected, PreSubmitExecutionAttempt): - await self._materialization.authorize_original_replay( - request, generation_id=UUID(selected.prepared_generation_id), - key=preparation_request.idempotency_key, - ) - return await self._attempts.read_completed( - row=selected, context=context, plan=request.effective_plan, - ) - return selected - - async def execute_reserved( - self, request: PreparedBundleMaterializationRequest, reservation: object, *, - preparation_request: SubmissionBundlePreparationRequest, - ) -> PreSubmitEvidencePersistenceResult: - """Run a committed winning claim, then atomically persist its canonical evidence.""" - if self._session.in_transaction(): - raise RuntimeError("pre-submit execution requires a transaction-free session") - claim = await self._attempts.committed_claim(reservation) - async with self._session.begin(): - await self._lock_authorized_context(request, preparation_request) - handle = await self._materialization.prepare_authorization( - task_id=request.task_id, assignment_id=request.assignment_id, - submission_artifact_policy_id=request.submission_artifact_policy_id, - checker_policy_id=request.checker_policy_id, - prepared_artifact=request.prepared_artifact, - effective_plan=request.effective_plan, - idempotency_key=preparation_request.idempotency_key, - ) - execution = await self._materialization.materialize_prepared_bundle( - replace(request, prepared_authorization=handle), claim=claim, - ) - async with self._session.begin(): - await self._session.execute(text("set transaction isolation level read committed")) - values = self._input(request, preparation_request) - await self._lock_authorized_context(request, preparation_request) - return await self._evidence_service().persist(PreSubmitEvidencePersistenceRequest( - **{name: getattr(values, name) for name in values.__dataclass_fields__}, - execution=execution, attempt=claim, manifest=request.manifest, - )) diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 9c1b19189..c5c62faf8 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -34,7 +34,7 @@ } ) _RESOURCE_TYPES = frozenset( - """task_post_submit_routing_manifest joint_lifecycle_control checker_run outbox_event actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task + """task_post_submit_routing_manifest joint_lifecycle_control checker_run selected_pre_submit_run outbox_event actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task submission submission_creation submission_binding review contribution compensation_award compensation_delivery compensation_adapter_binding contribution_policy operations audit_event project_create_operation project_submission_artifact_policy_mutation project_guide_activation pre_submit_checker_input project_guide_compilation_request diff --git a/backend/app/modules/authorization/admin_schemas.py b/backend/app/modules/authorization/admin_schemas.py index d0fc7e9f7..9df53da77 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[78] + total: Literal[80] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index dd55ba7cf..86d4e0a79 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -24,6 +24,8 @@ class PermissionId(StrEnum): ACTOR_IDENTITY_LINK_REACTIVATE = "actor.identity_link.reactivate" TASK_ASSIGNMENT_AUTHORITY_RECONCILE = "task.assignment.authority_reconcile" TASK_POST_SUBMIT_ROUTE = "task.post_submit.route" + CHECKER_PRE_SUBMIT_EXECUTE = "checker.pre_submit.execute" + CHECKER_PRE_SUBMIT_FINALIZE = "checker.pre_submit.finalize" CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" OUTBOX_DISPATCH = "outbox.dispatch" @@ -143,6 +145,8 @@ class ActionId(StrEnum): ACTOR_IDENTITY_LINK_REACTIVATE = "actor.identity_link.reactivate" TASK_ASSIGNMENT_AUTHORITY_RECONCILE = "task.assignment.authority_reconcile" TASK_POST_SUBMIT_ROUTE = "task.post_submit.route" + CHECKER_PRE_SUBMIT_EXECUTE = "checker.pre_submit.execute" + CHECKER_PRE_SUBMIT_FINALIZE = "checker.pre_submit.finalize" CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" OUTBOX_DISPATCH = "outbox.dispatch" @@ -675,6 +679,8 @@ def _active( 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( @@ -740,6 +746,8 @@ def _active( { PermissionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, PermissionId.OUTBOX_DISPATCH, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, @@ -774,9 +782,9 @@ def _active( def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 78 or len(ACTION_IDS) != 150: + if len(PERMISSION_IDS) != 80 or len(ACTION_IDS) != 152: raise RuntimeError("authorization catalogue count mismatch") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 29: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 31: raise RuntimeError("authorization permission boundary mismatch") @@ -861,8 +869,7 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, - ActionId.PROJECT_CREATE, - ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_CREATE, ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, @@ -897,8 +904,8 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp 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_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, @@ -930,6 +937,7 @@ 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.OUTBOX_DISPATCHER: frozenset({ActionId.OUTBOX_DISPATCH}), @@ -982,6 +990,8 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp (identity, action) for identity, action in ( (ServiceIdentity.TASK_POST_SUBMIT_ROUTER, ActionId.TASK_POST_SUBMIT_ROUTE), + (ServiceIdentity.CHECKER_PRE_SUBMIT, ActionId.CHECKER_PRE_SUBMIT_EXECUTE), + (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.TASK_ASSIGNMENT_RECONCILER, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE), @@ -1026,6 +1036,8 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp _ACTIVE_SERVICE_ACTIONS = { + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionId.TASK_POST_SUBMIT_ROUTE, ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, @@ -1052,6 +1064,8 @@ def _index_service_actions( ) -> 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), diff --git a/backend/app/modules/authorization/domain/action_groups.py b/backend/app/modules/authorization/domain/action_groups.py index 027bf6fee..a8ad94f8f 100644 --- a/backend/app/modules/authorization/domain/action_groups.py +++ b/backend/app/modules/authorization/domain/action_groups.py @@ -43,6 +43,8 @@ ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionId.TASK_QUEUE_READ, ActionId.PROJECT_TASK_QUEUE_READ, ActionId.OPERATIONS_TASK_QUEUE_READ, ActionId.PROJECT_CREATE, *GUIDE_BOUND_PROJECT_MANAGER_ACTIONS, diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index 369d46a04..1b956804d 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -17,6 +17,7 @@ "submission_binding", "outbox_event", "checker_run", + "selected_pre_submit_run", "task_post_submit_routing_manifest", "task_authority", "task_submission_history", @@ -50,6 +51,7 @@ "joint_lifecycle_control", "outbox_event", "checker_run", + "selected_pre_submit_run", "task_post_submit_routing_manifest", "task_authority", "task_submission_history", diff --git a/backend/app/modules/authorization/domain/audit_targets.py b/backend/app/modules/authorization/domain/audit_targets.py index 25c821557..cad2c5dd5 100644 --- a/backend/app/modules/authorization/domain/audit_targets.py +++ b/backend/app/modules/authorization/domain/audit_targets.py @@ -6,6 +6,7 @@ from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.domain.post_submit_routing import PostSubmitRoutingResourceContext from app.modules.authorization.domain.post_submit import PostSubmitResourceContext +from app.modules.authorization.domain.pre_submit import PreSubmitResourceContext from app.modules.authorization.domain.assignment_invalidation import AssignmentInvalidationResourceContext from app.modules.authorization.domain.outbox_dispatch import OutboxDispatchResourceContext from app.modules.authorization.domain.guide_proposals import GuideProposalResourceContext @@ -107,7 +108,7 @@ def project_authority_audit_target( AdapterBindingMutationResourceContext, ProjectGuideProjectionResourceContext, ProjectSetupFinalizationResourceContext, - GuideProposalResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, PostSubmitResourceContext, PostSubmitRoutingResourceContext, + GuideProposalResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, PreSubmitResourceContext, PostSubmitResourceContext, PostSubmitRoutingResourceContext, ), ): project_id = str(getattr(resource, "project_id", None) or resource.scope_project_id) diff --git a/backend/app/modules/authorization/domain/pre_submit.py b/backend/app/modules/authorization/domain/pre_submit.py new file mode 100644 index 000000000..e3489ac53 --- /dev/null +++ b/backend/app/modules/authorization/domain/pre_submit.py @@ -0,0 +1,121 @@ +"""Exact fixed-service resources for selected pre-submit execution phases.""" + +from __future__ import annotations + +import re +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, model_validator + +from app.modules.authorization.catalogue import ActionId +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitExecuteFacts, + SelectedPreSubmitFinalizeFacts, + SelectedPreSubmitReservationFacts, +) + + +EXECUTE = ActionId.CHECKER_PRE_SUBMIT_EXECUTE +FINALIZE = ActionId.CHECKER_PRE_SUBMIT_FINALIZE +PRE_SUBMIT_ACTIONS = frozenset({EXECUTE, FINALIZE}) + + +class PreSubmitResourceContext(BaseModel): + """One selected run phase under its exact project and durable request.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + resource_type: Literal["selected_pre_submit_run"] = "selected_pre_submit_run" + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + facts: SelectedPreSubmitExecuteFacts | SelectedPreSubmitFinalizeFacts + + @model_validator(mode="after") + def validate_identity(self): + expected = ( + SelectedPreSubmitExecuteFacts + if self.action_id is EXECUTE + else SelectedPreSubmitFinalizeFacts + if self.action_id is FINALIZE + else None + ) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid selected PRE authority phase") + reservation = self.facts.lease.reservation + if ( + self.resource_id != reservation.attempt_id + or self.scope_project_id != reservation.project_id + ): + raise ValueError("invalid selected PRE authority resource") + return self + + +def pre_submit_resource( + action: ActionId, + facts: SelectedPreSubmitExecuteFacts | SelectedPreSubmitFinalizeFacts, +) -> PreSubmitResourceContext: + """Build a typed exact-run resource without provider or workspace facts.""" + reservation = facts.lease.reservation + return PreSubmitResourceContext( + action_id=action, + resource_id=reservation.attempt_id, + scope_project_id=reservation.project_id, + facts=facts, + ) + + +def pre_submit_prepare_values( + reservation: SelectedPreSubmitReservationFacts, +) -> dict[str, str]: + """Bind fixed-service preparation to the selected request before owner locks.""" + return { + "project_id": str(reservation.project_id), + "run_id": str(reservation.attempt_id), + "request_digest": reservation.request_digest, + } + + +def parse_pre_submit_prepare(action, values, invalid_error): + """Reject extra or malformed fixed-service selectors.""" + if action not in PRE_SUBMIT_ACTIONS: + return None + try: + if set(values) != {"project_id", "run_id", "request_digest"}: + raise ValueError("invalid keys") + for key in ("project_id", "run_id"): + if type(values[key]) is not str or str(UUID(values[key])) != values[key]: + raise ValueError("invalid identity") + if ( + type(values["request_digest"]) is not str + or re.fullmatch(r"sha256:[0-9a-f]{64}", values["request_digest"]) + is None + ): + raise ValueError("invalid digest") + except (KeyError, TypeError, ValueError) as error: + raise invalid_error("invalid prepared selected PRE authority") from error + return dict(values) + + +def pre_submit_prepare_matches(action, prepared, resource) -> bool: + """Match final phase facts to the exact prepared selected request.""" + if ( + action not in PRE_SUBMIT_ACTIONS + or type(resource) is not PreSubmitResourceContext + or resource.action_id is not action + ): + return False + resource.validate_identity() + return prepared == pre_submit_prepare_values(resource.facts.lease.reservation) + + +__all__ = ( + "EXECUTE", + "FINALIZE", + "PRE_SUBMIT_ACTIONS", + "PreSubmitResourceContext", + "parse_pre_submit_prepare", + "pre_submit_prepare_matches", + "pre_submit_prepare_values", + "pre_submit_resource", +) diff --git a/backend/app/modules/authorization/domain/prepared_service.py b/backend/app/modules/authorization/domain/prepared_service.py index f5e498f65..e99a2a46b 100644 --- a/backend/app/modules/authorization/domain/prepared_service.py +++ b/backend/app/modules/authorization/domain/prepared_service.py @@ -10,6 +10,11 @@ from app.modules.authorization.domain.post_submit import ( 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.outbox_dispatch import OutboxDispatchResourceContext, prepared_outbox_digest from app.modules.authorization.domain.post_policy import PostPolicyResourceContext, DERIVE @@ -107,7 +112,8 @@ def fixed_service_scope_project(action_id, scope, artifact_resource): outbox = (action_id in {ActionId.OUTBOX_DISPATCH, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE} 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 POST_SUBMIT_ACTIONS | {ROUTE} and scope.kind is PreparedAuthorityScopeKind.PROJECT + action_id in PRE_SUBMIT_ACTIONS | POST_SUBMIT_ACTIONS | {ROUTE} + and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None ): return scope.project_id @@ -125,6 +131,12 @@ def fixed_service_resource_matches(action_id, resource, project_id, artifact_typ 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) + if action_id in PRE_SUBMIT_ACTIONS: + return ( + type(resource) is PreSubmitResourceContext + and resource.action_id is action_id + 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 if action_id is ActionId.OUTBOX_DISPATCH: @@ -146,6 +158,9 @@ def prepared_fixed_service_bindings(action, request, invalid_error): if action is ROUTE: return {"routing_request": parse_post_submit_routing_prepare(action, request, invalid_error)} return { + "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), "outbox_dispatch_digest": prepared_outbox_digest(action, request, invalid_error), diff --git a/backend/app/modules/authorization/domain/resource_digest.py b/backend/app/modules/authorization/domain/resource_digest.py index c0b5b4f84..885ba838c 100644 --- a/backend/app/modules/authorization/domain/resource_digest.py +++ b/backend/app/modules/authorization/domain/resource_digest.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from app.modules.authorization.domain.post_submit_routing import PostSubmitRoutingResourceContext, post_submit_routing_resource_digest from app.modules.authorization.domain.post_submit import PostSubmitResourceContext, post_submit_digest +from app.modules.authorization.domain.pre_submit import PreSubmitResourceContext from app.modules.authorization.domain.assignment_invalidation import AssignmentInvalidationResourceContext from app.modules.tasks.api.assignment_invalidation import assignment_invalidation_resource_digest from app.modules.authorization.domain.outbox_dispatch import OutboxDispatchResourceContext @@ -30,6 +31,9 @@ def authorization_resource_digest(resource: BaseModel) -> str: if type(resource) is PostSubmitResourceContext: resource.validate_identity() return post_submit_digest(resource.facts) + if type(resource) is PreSubmitResourceContext: + resource.validate_identity() + return resource.facts.facts_digest if type(resource) is AssignmentInvalidationResourceContext: resource.validate_identity() return assignment_invalidation_resource_digest(resource.facts) diff --git a/backend/app/modules/authorization/post_submit_authorization.py b/backend/app/modules/authorization/post_submit_authorization.py index bca98ef86..ccd301d23 100644 --- a/backend/app/modules/authorization/post_submit_authorization.py +++ b/backend/app/modules/authorization/post_submit_authorization.py @@ -20,7 +20,7 @@ from app.modules.checkers.api.materialization import ( PreparedMaterialization, PostSubmissionMaterializationUnavailable, ) -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationRequest +from app.modules.checkers.api.evaluation_contract import validate_execution_request @contextmanager @@ -75,7 +75,7 @@ async def consume(self, facts): async def _prepare(session, action, request, *, execution=None): """Acquire fixed principal custody before any CHECKERS or ART feature locks.""" try: - PostSubmissionEvaluationRequest.model_validate_json(request.model_dump_json()) + validate_execution_request(request) prepare_values = post_submit_prepare_values(action, request, execution) except (AttributeError, ValueError, TypeError) as error: exception = PostSubmissionMaterializationUnavailable if action == MATERIALIZE else CheckerExecutionUnavailable @@ -105,7 +105,7 @@ def __init__(self, session): async def preflight(self, request): """Validate value shape; preparation admits and locks the live principal before owner access.""" - PostSubmissionEvaluationRequest.model_validate_json(request.model_dump_json()) + validate_execution_request(request) def prepare_execution(self, request): return _prepare(self._session, EXECUTE, request) diff --git a/backend/app/modules/authorization/pre_submit_authorization.py b/backend/app/modules/authorization/pre_submit_authorization.py new file mode 100644 index 000000000..d817018f5 --- /dev/null +++ b/backend/app/modules/authorization/pre_submit_authorization.py @@ -0,0 +1,105 @@ +"""Fixed selected-PRE principal through canonical AUTH/PREP.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager, contextmanager + +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.domain.pre_submit import ( + EXECUTE, + FINALIZE, + pre_submit_prepare_values, + pre_submit_resource, +) +from app.modules.authorization.prepared import fixed_service_prepared_authorization +from app.modules.authorization.runtime import ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, +) +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitAuthorityEvidence, + SelectedPreSubmitAuthorityUnavailable, +) + + +@contextmanager +def _authority_errors(): + try: + yield + except ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ) as error: + raise SelectedPreSubmitAuthorityUnavailable( + "selected_pre_submit_authority_unavailable" + ) from error + + +class _PreparedPhase: + def __init__(self, service, handle, caller_input, action): + self._service = service + self._handle = handle + self._input = caller_input + self._action = action + + async def consume(self, facts): + with _authority_errors(): + decision = await self._service.consume( + self._handle, + self._action, + self._input, + pre_submit_resource(self._action, facts), + ) + return SelectedPreSubmitAuthorityEvidence( + evidence_id=decision.decision_id, + facts_digest=facts.facts_digest, + ) + + +@asynccontextmanager +async def _prepare(session, action, reservation): + """Acquire the fixed principal before selected owner locks.""" + values = pre_submit_prepare_values(reservation) + with _authority_errors(): + async with fixed_service_prepared_authorization( + session, + service_identity=ServiceIdentity.CHECKER_PRE_SUBMIT, + request_id=reservation.attempt_id, + correlation_id=reservation.attempt_id, + ) as authority: + caller_input = PreparedAuthorizationInput( + idempotency_key=reservation.attempt_id, + request_value=values, + ) + handle = await authority.service.prepare( + action, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=reservation.project_id, + ), + ) + yield _PreparedPhase(authority.service, handle, caller_input, action) + + +class SelectedPreSubmitAuthorization: + """Expose only execute and finalize for the fixed selected-PRE principal.""" + + def __init__(self, session): + self._session = session + + def prepare_execution(self, reservation): + return _prepare(self._session, EXECUTE, reservation) + + def prepare_finalization(self, facts): + return _prepare(self._session, FINALIZE, facts.lease.reservation) + + +__all__ = ("SelectedPreSubmitAuthorization",) diff --git a/backend/app/modules/authorization/pre_submit_materialization.py b/backend/app/modules/authorization/pre_submit_materialization.py index 82fbdce76..f7a21896a 100644 --- a/backend/app/modules/authorization/pre_submit_materialization.py +++ b/backend/app/modules/authorization/pre_submit_materialization.py @@ -6,7 +6,11 @@ from app.core.hashing import canonical_json_hash from app.modules.authorization.catalogue import ActionId from app.modules.authorization.domain.resource_digest import authorization_resource_digest -from app.modules.authorization.runtime import PreSubmitCheckerInputPreparationContext, TaskImportSourceResourceContext +from app.modules.authorization.runtime import ( + PreSubmitCheckerInputPreparationContext, TaskImportSourceResourceContext, + PreSubmitCheckerInputResourceContext, SubmissionBindingResourceContext, + SubmissionCreationResourceContext, +) from app.modules.authorization.submission_consumption import parse_consumption_binding from app.modules.authorization.submission_preparation import ( parse_submission_preparation_or_invalid, submission_preparation_binding_fields, @@ -70,3 +74,22 @@ def parse_project_create_binding(raw: dict, invalid_error): if type(generation) is not int or generation < 1 or operation_id == project_id: raise invalid_error("invalid prepared authorization handle") return operation_id, project_id, generation + +def exact_artifact_binding_matches( + prepared_context: dict | None, + prepared_digest: str | None, + resource: PreSubmitCheckerInputResourceContext + | SubmissionBindingResourceContext + | SubmissionCreationResourceContext, +) -> bool: + """Require every materialization fact to equal the prepared request.""" + if isinstance(resource, PreSubmitCheckerInputResourceContext): + context = resource.model_dump(mode="json", exclude={"semantic_manifest_sha256"}) + return prepared_context == context and ( + prepared_digest + == canonical_json_hash({"pre_submit_checker_input_preparation": context}) + ) + context = resource.model_dump(mode="json") + return prepared_context == context and ( + prepared_digest == authorization_resource_digest(resource) + ) diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index e57069d45..bc9eacd2d 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -17,7 +17,6 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.core.hashing import canonical_json_hash from app.modules.actors.repository import ActorRepository from app.modules.actors.api import ServiceIdentity from app.modules.audit.schemas import ActorReferenceKind @@ -130,10 +129,15 @@ ) from app.modules.tasks.api.post_submit_routing import TaskRoutingRequestFacts from app.modules.authorization.domain.post_submit import POST_SUBMIT_ACTIONS, PostSubmitResourceContext, post_submit_prepare_matches +from app.modules.authorization.domain.pre_submit import ( + PRE_SUBMIT_ACTIONS, + PreSubmitResourceContext, + pre_submit_prepare_matches, +) from app.modules.authorization.prepared_submission_replay import validate_submission_replay from app.modules.authorization.prepared_post_submit_replay import validate_post_submit_replay from app.modules.authorization.pre_submit_materialization import ( - parse_prepared_artifact_bindings, + parse_prepared_artifact_bindings, exact_artifact_binding_matches, parse_project_create_binding, ) @@ -200,6 +204,7 @@ class _PreparedAuthorizationBinding: assignment_invalidation_context: AssignmentInvalidationResourceContext | None = None outbox_dispatch_digest: str | None = None post_submit_prepare_context: dict | None = None + pre_submit_prepare_context: dict | None = None routing_request: TaskRoutingRequestFacts | None = None task_authority_context: TaskAuthorityResourceContext | None = None lifecycle_command: LifecycleTransitionCommand | None = None @@ -390,25 +395,6 @@ def _submission_policy_binding_matches( ) and binding.submission_policy_resource_digest == authorization_resource_digest(resource) -def _exact_artifact_binding_matches( - binding: _PreparedAuthorizationBinding, - resource: PreSubmitCheckerInputResourceContext - | SubmissionBindingResourceContext - | SubmissionCreationResourceContext, -) -> bool: - """Require every materialization fact to equal the prepared request.""" - if isinstance(resource, PreSubmitCheckerInputResourceContext): - context = resource.model_dump(mode="json", exclude={"semantic_manifest_sha256"}) - return binding.exact_artifact_context == context and ( - binding.exact_artifact_resource_digest - == canonical_json_hash({"pre_submit_checker_input_preparation": context}) - ) - context = resource.model_dump(mode="json") - return binding.exact_artifact_context == context and ( - binding.exact_artifact_resource_digest == authorization_resource_digest(resource) - ) - - def _submission_preparation_binding_matches( binding: _PreparedAuthorizationBinding, resource: SubmissionBundlePreparationPreflightResourceContext @@ -640,6 +626,12 @@ def _validate_consumption( expected_action_id, issuance.binding.post_submit_prepare_context, final_resource_context, ): raise PreparedAuthorizationHandleInvalid("invalid prepared post-submit authority") + if expected_action_id in PRE_SUBMIT_ACTIONS and not pre_submit_prepare_matches( + expected_action_id, + issuance.binding.pre_submit_prepare_context, + final_resource_context, + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared selected PRE authority") if expected_action_id is ActionId.OUTBOX_DISPATCH and ( type(final_resource_context) is not OutboxDispatchResourceContext or issuance.binding.outbox_dispatch_digest != authorization_resource_digest(final_resource_context) @@ -715,7 +707,10 @@ async def consume( SubmissionCreationResourceContext, TaskImportSourceResourceContext, ), - ) and not _exact_artifact_binding_matches(issuance.binding, final_resource_context): + ) and not exact_artifact_binding_matches( + issuance.binding.exact_artifact_context, + issuance.binding.exact_artifact_resource_digest, final_resource_context, + ): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") if isinstance( final_resource_context, SubmissionBundlePreparationResourceContext @@ -957,7 +952,7 @@ def _scope_from_resource( artifact_resource_type=artifact_resource[0], artifact_resource_id=resource.resource_id, ) - if isinstance(resource, (ProjectGuideProjectionResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, AssignmentInvalidationResourceContext, PostSubmitResourceContext, PostSubmitRoutingResourceContext)): + if isinstance(resource, (ProjectGuideProjectionResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, AssignmentInvalidationResourceContext, PreSubmitResourceContext, PostSubmitResourceContext, PostSubmitRoutingResourceContext)): return PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 2078e0c8c..b188ae70c 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -36,6 +36,7 @@ from app.modules.authorization.domain.outbox_dispatch import OutboxDispatchResourceContext from app.modules.authorization.domain.post_submit_routing import PostSubmitRoutingResourceContext from app.modules.authorization.domain.post_submit import PostSubmitResourceContext +from app.modules.authorization.domain.pre_submit import PreSubmitResourceContext from app.modules.authorization.domain.guide_activation import ProjectGuideActivationResourceContext from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.service_actor_schemas import ServiceActorProvisionResourceContext @@ -1064,6 +1065,7 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte | PostPolicyResourceContext | AssignmentInvalidationResourceContext | OutboxDispatchResourceContext + | PreSubmitResourceContext | PostSubmitResourceContext | PostSubmitRoutingResourceContext | ProjectSetupRunMutationResourceContext diff --git a/backend/app/modules/authorization/submission_consumption.py b/backend/app/modules/authorization/submission_consumption.py index f393c3403..427481561 100644 --- a/backend/app/modules/authorization/submission_consumption.py +++ b/backend/app/modules/authorization/submission_consumption.py @@ -74,7 +74,8 @@ class SubmissionBindingResourceContext(BaseModel): resource_type: Literal["submission_binding"] resource_id: UUID admission_id: UUID - evidence_set_id: UUID + evidence_set_id: UUID | None + selected_pre_submit_run_id: UUID | None actor_profile_id: UUID identity_link_id: UUID project_id: UUID @@ -110,6 +111,10 @@ def bind_admission(self): self.predecessor_submission_version is None ): raise ValueError("submission binding predecessor is incomplete") + if (self.evidence_set_id is None) == ( + self.selected_pre_submit_run_id is None + ): + raise ValueError("submission binding PRE lineage is incomplete") if (self.predecessor_submission_id is None) != ( self.checker_remediation_manifest_id is None ): diff --git a/backend/app/modules/authorization/submission_preparation.py b/backend/app/modules/authorization/submission_preparation.py index 85395db50..c34842b4d 100644 --- a/backend/app/modules/authorization/submission_preparation.py +++ b/backend/app/modules/authorization/submission_preparation.py @@ -37,7 +37,11 @@ class SubmissionBundlePreparationResourceContext(SubmissionBundlePreparationRequ resource_id: UUID predecessor_submission_version: int | None = Field(default=None, ge=1) checker_remediation_manifest_id: UUID | None = None - pre_submit_evidence_set_id: UUID + pre_submit_evidence_set_id: UUID | None + selected_pre_submit_run_id: UUID | None = None + selected_terminal_digest: str | None = Field( + default=None, pattern=r"^sha256:[0-9a-f]{64}$" + ) prepared_generation_id: UUID guide_id: UUID guide_version: str @@ -50,6 +54,8 @@ class SubmissionBundlePreparationResourceContext(SubmissionBundlePreparationRequ effective_plan_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") semantic_manifest_id: UUID semantic_manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + semantic_manifest_body: dict[str, object] + packet_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") archive_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") archive_byte_count: int = Field(ge=0) media_type: Literal["application/zip"] @@ -66,6 +72,13 @@ def require_exact_identity_and_predecessor(self): self.predecessor_submission_version is None ): raise ValueError("submission predecessor identity is incomplete") + if ( + (self.pre_submit_evidence_set_id is None) + == (self.selected_pre_submit_run_id is None) + or (self.selected_pre_submit_run_id is None) + != (self.selected_terminal_digest is None) + ): + raise ValueError("submission preparation lineage is incomplete") if (self.predecessor_submission_id is None) != ( self.checker_remediation_manifest_id is None ): @@ -96,7 +109,6 @@ def parse_submission_preparation( if "prepared_generation_id" not in value: return request_context, request_digest, None, None for field in ( - "pre_submit_evidence_set_id", "prepared_generation_id", "guide_id", "source_snapshot_id", @@ -105,10 +117,15 @@ def parse_submission_preparation( "semantic_manifest_id", ): value[field] = UUID(str(value[field])) + for field in ("pre_submit_evidence_set_id", "selected_pre_submit_run_id"): + if value.get(field) is not None: + value[field] = UUID(str(value[field])) if value.get("replay_durable_intent_id") is not None: value["replay_durable_intent_id"] = UUID(str(value["replay_durable_intent_id"])) if value.get("checker_remediation_manifest_id") is not None: - value["checker_remediation_manifest_id"] = UUID(str(value["checker_remediation_manifest_id"])) + value["checker_remediation_manifest_id"] = UUID( + str(value["checker_remediation_manifest_id"]) + ) final = SubmissionBundlePreparationResourceContext( resource_type="submission_bundle_preparation", resource_id=value["prepared_generation_id"], diff --git a/backend/app/modules/checkers/api/__init__.py b/backend/app/modules/checkers/api/__init__.py index 640ecce41..1e2fcea36 100644 --- a/backend/app/modules/checkers/api/__init__.py +++ b/backend/app/modules/checkers/api/__init__.py @@ -28,68 +28,43 @@ make_external_checker_execution_result, make_external_checker_registration_request, ) -from app.modules.checkers.api.pre_submit import ( +from app.modules.checkers.api.pre_submit_material import ( ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, - EffectivePreSubmissionExecutionPlan, - EffectivePreSubmissionPlanEntry, - EffectivePreSubmissionPlanError, - EffectivePreSubmissionPlanLineage, - EffectivePreSubmissionPlanningPort, - FrozenJsonObject, - PreSubmissionExecutionEntryFacts, - PreSubmissionExecutionFacts, - PreSubmissionInfrastructureUnavailableError, - PRE_SUBMISSION_RESULT_METADATA_KEYS, SubmissionPacketView, - validate_pre_submission_execution_facts, +) +from app.modules.checkers.api.selected_pre_submit import ( + ExternalPreSubmitIsolationFacts, + ExternalPreSubmitMemberFacts, + SelectedPreSubmitExecutionFacts, + SelectedPreSubmitInfrastructureUnavailable, ) __all__ = ( "ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES", - "EffectivePreSubmissionExecutionPlan", - "EffectivePreSubmissionPlanEntry", - "EffectivePreSubmissionPlanError", - "EffectivePreSubmissionPlanLineage", - "EffectivePreSubmissionPlanningPort", - "FrozenJsonObject", - "PreSubmissionExecutionEntryFacts", - "PreSubmissionExecutionFacts", - "PreSubmissionInfrastructureUnavailableError", - "PRE_SUBMISSION_RESULT_METADATA_KEYS", "SubmissionPacketView", - "validate_pre_submission_execution_facts", + "ExternalPreSubmitIsolationFacts", + "ExternalPreSubmitMemberFacts", + "SelectedPreSubmitExecutionFacts", + "SelectedPreSubmitInfrastructureUnavailable", ) -from app.modules.checkers.api.post_submit_catalogue import ( - CompiledPostSubmitPolicy, - EmptyPostSubmitConfiguration, - PostSubmitCatalogue, - PostSubmitDefinition, - PostSubmitPolicyEntry, -) -from app.modules.checkers.api.post_submit import ( - ExpectedPostSubmitContext, - ObservedPostSubmitContext, - PostSubmissionEvaluationContent, - make_post_submit_request, - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, - PostSubmissionExecutionPort, - PostSubmissionStructuralInput, - PostSubmitCurrentResultReference, - PostSubmitEvidenceEntry, - PostSubmitManifestEntry, - PostSubmitMemberResult, - PostSubmitPolicyInputs, +from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmitCurrentResultReference, + SelectedPostSubmissionContent, + SelectedPostSubmissionEvaluationRequest, + SelectedPostSubmissionEvaluationResult, + SelectedPostSubmitExecutionFacts, + SelectedPostSubmitLockedContext, + make_selected_post_submit_request, + make_selected_post_submit_result, ) __all__ += ( - "CompiledPostSubmitPolicy", "EmptyPostSubmitConfiguration", "PostSubmitCatalogue", - "PostSubmitDefinition", "PostSubmitPolicyEntry", "ExpectedPostSubmitContext", - "ObservedPostSubmitContext", "PostSubmissionEvaluationContent", "make_post_submit_request", "PostSubmissionEvaluationRequest", "PostSubmissionEvaluationResult", - "PostSubmissionExecutionPort", "PostSubmissionStructuralInput", - "PostSubmitCurrentResultReference", "PostSubmitEvidenceEntry", "PostSubmitManifestEntry", - "PostSubmitMemberResult", "PostSubmitPolicyInputs", + "SelectedPostSubmitCurrentResultReference", + "SelectedPostSubmissionContent", "SelectedPostSubmissionEvaluationRequest", + "SelectedPostSubmissionEvaluationResult", "SelectedPostSubmitExecutionFacts", + "SelectedPostSubmitLockedContext", "make_selected_post_submit_request", + "make_selected_post_submit_result", ) __all__ += ( diff --git a/backend/app/modules/checkers/api/canonical_values.py b/backend/app/modules/checkers/api/canonical_values.py new file mode 100644 index 000000000..0d3bd1ada --- /dev/null +++ b/backend/app/modules/checkers/api/canonical_values.py @@ -0,0 +1,52 @@ +"""Canonical bounded scalar values shared by the selected checker flow.""" + +from __future__ import annotations + +import json +from typing import Annotated +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr + +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_.-]*$") +] +VersionNumber = Annotated[StrictInt, Field(ge=1, le=2_147_483_647)] +GuideVersion = Annotated[StrictStr, Field(min_length=1, max_length=50, pattern=r"\S")] +ResourceId = Annotated[UUID, Field(strict=True)] +ByteCount = Annotated[StrictInt, Field(ge=0, le=9_223_372_036_854_775_807)] + + +class CheckerValue(BaseModel): + """Reject unknown fields and revalidate previously constructed instances.""" + + model_config = ConfigDict(extra="forbid", frozen=True, revalidate_instances="always") + + +def canonical_checker_bytes( + value: CheckerValue, + *, + exclude: set[str] | None = None, + include: set[str] | None = None, +) -> bytes: + """Use the canonical hash encoding for bounded serialized facts.""" + return json.dumps( + value.model_dump(mode="json", exclude=exclude, include=include), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +__all__ = ( + "ByteCount", + "CheckerValue", + "GuideVersion", + "Identifier", + "ResourceId", + "Sha256", + "VersionNumber", + "canonical_checker_bytes", +) diff --git a/backend/app/modules/checkers/api/default_checker.py b/backend/app/modules/checkers/api/default_checker.py new file mode 100644 index 000000000..e869e6f71 --- /dev/null +++ b/backend/app/modules/checkers/api/default_checker.py @@ -0,0 +1,326 @@ +"""Closed contract for the one Workstream-owned default checker.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.core.hashing import canonical_json_hash +from app.modules.checkers.api.material import SubmissionMaterialEntry +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView + + +WORKSTREAM_DEFAULT_CHECKER_ID = "workstream.default" +WORKSTREAM_DEFAULT_CHECKER_VERSION = "v1" +WORKSTREAM_DEFAULT_CHECK_IDS = ( + "archive_safe", + "received_identity_exact", + "high_confidence_secrets_absent", + "submission_changed_for_current_rules", +) +WORKSTREAM_DEFAULT_WARNING_CODES = ( + "submission_summary_missing", + "submission_attestation_missing", +) +WORKSTREAM_DEFAULT_SECRET_SIGNATURES = ( + ( + "pem_private_key", + r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----", + 0, + ), + ("aws_access_key_id", r"(? None: + if ( + type(value) is not str + or len(value) != 71 + or not value.startswith("sha256:") + or any(character not in "0123456789abcdef" for character in value[7:]) + ): + raise ValueError("default checker digest is invalid") + + +@dataclass(frozen=True, slots=True) +class WorkstreamDefaultPredecessor: + """Detached canonical predecessor identity supplied by the TASK/ART owners.""" + + submission_id: UUID + submission_version: int + archive_sha256: str + semantic_manifest_sha256: str + + def __post_init__(self) -> None: + """Reject incomplete or caller-shaped predecessor identities.""" + if type(self.submission_id) is not UUID: + raise ValueError("default checker predecessor id is invalid") + if type(self.submission_version) is not int or self.submission_version <= 0: + raise ValueError("default checker predecessor version is invalid") + _require_sha256(self.archive_sha256) + _require_sha256(self.semantic_manifest_sha256) + + +@dataclass(frozen=True, slots=True) +class WorkstreamDefaultCheckerInput: + """Detached exact custody and task-rule facts; never storage authority.""" + + archive_sha256: str + archive_byte_count: int + archive_media_type: str + semantic_manifest_sha256: str + entries: tuple[SubmissionMaterialEntry, ...] + predecessor: WorkstreamDefaultPredecessor | None + predecessor_exists: bool + current_predecessor: WorkstreamDefaultPredecessor | None + current_rules_sha256: str + predecessor_rules_sha256: str | None + packet: SubmissionPacketView + + def __post_init__(self) -> None: + """Reject malformed detached facts before any callback-scoped byte read.""" + _require_sha256(self.archive_sha256) + _require_sha256(self.semantic_manifest_sha256) + _require_sha256(self.current_rules_sha256) + if ( + type(self.archive_byte_count) is not int + or self.archive_byte_count <= 0 + or self.archive_media_type != "application/zip" + or type(self.entries) is not tuple + or any(type(item) is not SubmissionMaterialEntry for item in self.entries) + or ( + self.predecessor is not None + and type(self.predecessor) is not WorkstreamDefaultPredecessor + ) + or ( + self.current_predecessor is not None + and type(self.current_predecessor) is not WorkstreamDefaultPredecessor + ) + or type(self.predecessor_exists) is not bool + or type(self.packet) is not SubmissionPacketView + or (self.predecessor_rules_sha256 is None) != (self.predecessor is None) + ): + raise ValueError("default checker input is invalid") + if self.predecessor_rules_sha256 is not None: + _require_sha256(self.predecessor_rules_sha256) + + +def workstream_default_input_body( + value: WorkstreamDefaultCheckerInput, +) -> dict[str, object]: + """Return the exact detached input committed by durable PRE reservation.""" + if type(value) is not WorkstreamDefaultCheckerInput: + raise ValueError("default checker input is invalid") + + def predecessor_body(item: WorkstreamDefaultPredecessor | None): + return ( + None + if item is None + else { + "submission_id": str(item.submission_id), + "submission_version": item.submission_version, + "archive_sha256": item.archive_sha256, + "semantic_manifest_sha256": item.semantic_manifest_sha256, + } + ) + + return { + "schema_version": "workstream.default_checker_input.v1", + "archive_sha256": value.archive_sha256, + "archive_byte_count": value.archive_byte_count, + "archive_media_type": value.archive_media_type, + "semantic_manifest_sha256": value.semantic_manifest_sha256, + "entries": [ + { + "normalized_path": item.normalized_path, + "entry_type": item.entry_type, + "byte_count": item.byte_count, + "sha256": item.sha256, + "executable": item.executable, + } + for item in value.entries + ], + "predecessor": predecessor_body(value.predecessor), + "predecessor_exists": value.predecessor_exists, + "current_predecessor": predecessor_body(value.current_predecessor), + "current_rules_sha256": value.current_rules_sha256, + "predecessor_rules_sha256": value.predecessor_rules_sha256, + "packet_sha256": value.packet.sha256, + } + + +def workstream_default_input_sha256(value: WorkstreamDefaultCheckerInput) -> str: + """Bind every executable default-checker input without private file bytes.""" + return canonical_json_hash(workstream_default_input_body(value)) + + +class WorkstreamDefaultCheckerValue(BaseModel): + """Strict immutable value emitted by the default checker.""" + + model_config = ConfigDict(extra="forbid", frozen=True, revalidate_instances="always") + + +class WorkstreamDefaultCheck(WorkstreamDefaultCheckerValue): + """One of the four ordered blocking default outcomes.""" + + check_id: WorkstreamDefaultCheckId + status: WorkstreamDefaultCheckStatus + failure_code: WorkstreamDefaultFailureCode | None = None + finding_count: int = Field(ge=0) + + @model_validator(mode="after") + def validate_outcome(self) -> Self: + """Keep failures explicit and passes/not-run values finding-free.""" + if (self.status == "failed") != ( + self.failure_code is not None and self.finding_count > 0 + ): + raise ValueError("default checker outcome shape is invalid") + if self.status != "failed" and ( + self.failure_code is not None or self.finding_count != 0 + ): + raise ValueError("default checker non-failure outcome is invalid") + if self.status == "failed" and not _failure_matches_check( + self.check_id, self.failure_code + ): + raise ValueError("default checker failure does not match its check") + return self + + +class WorkstreamDefaultWarning(WorkstreamDefaultCheckerValue): + """One warning-only packet observation.""" + + code: WorkstreamDefaultWarningCode + + +class WorkstreamDefaultCheckerResult(WorkstreamDefaultCheckerValue): + """Complete result from the one versioned Workstream default checker.""" + + schema_version: Literal["workstream.default_checker_result.v1"] = ( + "workstream.default_checker_result.v1" + ) + checker_id: Literal["workstream.default"] = WORKSTREAM_DEFAULT_CHECKER_ID + checker_version: Literal["v1"] = WORKSTREAM_DEFAULT_CHECKER_VERSION + behavior_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + eligible: bool + checks: tuple[WorkstreamDefaultCheck, ...] = Field(min_length=4, max_length=4) + warnings: tuple[WorkstreamDefaultWarning, ...] = Field(max_length=2) + + @model_validator(mode="after") + def validate_complete_result(self) -> Self: + """Require the exact four defaults, stable behavior and derived eligibility.""" + if self.behavior_sha256 != WORKSTREAM_DEFAULT_BEHAVIOR_SHA256: + raise ValueError("default checker behavior identity differs") + if tuple(item.check_id for item in self.checks) != WORKSTREAM_DEFAULT_CHECK_IDS: + raise ValueError("default checker result is incomplete or reordered") + warning_codes = tuple(item.code for item in self.warnings) + if ( + len(warning_codes) != len(set(warning_codes)) + or warning_codes != tuple( + code for code in WORKSTREAM_DEFAULT_WARNING_CODES if code in warning_codes + ) + ): + raise ValueError("default checker warnings are duplicated or reordered") + if self.eligible != all(item.status == "passed" for item in self.checks): + raise ValueError("default checker eligibility differs from blocking checks") + return self + + +def _failure_matches_check( + check_id: WorkstreamDefaultCheckId, + failure_code: WorkstreamDefaultFailureCode | None, +) -> bool: + """Bind every closed failure category to exactly one blocking default.""" + if failure_code is None: + return False + if check_id == "archive_safe": + return failure_code.startswith("submission_archive_") and failure_code not in { + "submission_archive_unchanged" + } + if check_id == "high_confidence_secrets_absent": + return failure_code == "high_confidence_secret_detected" + if check_id == "submission_changed_for_current_rules": + return failure_code in { + "submission_archive_unchanged", + "submission_manifest_unchanged", + } + return False + + +__all__ = ( + "WORKSTREAM_DEFAULT_BEHAVIOR_MANIFEST", + "WORKSTREAM_DEFAULT_BEHAVIOR_SHA256", + "WORKSTREAM_DEFAULT_CHECKER_ID", + "WORKSTREAM_DEFAULT_CHECKER_VERSION", + "WORKSTREAM_DEFAULT_CHECK_IDS", + "WORKSTREAM_DEFAULT_SECRET_SIGNATURES", + "WORKSTREAM_DEFAULT_WARNING_CODES", + "WorkstreamDefaultArchiveFailureCode", + "WorkstreamDefaultCheck", + "WorkstreamDefaultCheckId", + "WorkstreamDefaultCheckerInput", + "WorkstreamDefaultCheckerResult", + "WorkstreamDefaultFailureCode", + "WorkstreamDefaultPredecessor", + "WorkstreamDefaultWarning", + "workstream_default_input_body", + "workstream_default_input_sha256", +) diff --git a/backend/app/modules/checkers/api/evaluation_contract.py b/backend/app/modules/checkers/api/evaluation_contract.py new file mode 100644 index 000000000..f08c6e17c --- /dev/null +++ b/backend/app/modules/checkers/api/evaluation_contract.py @@ -0,0 +1,48 @@ +"""Closed selected POST envelopes for the single execution owner.""" + +from __future__ import annotations + +from typing import TypeAlias + +from pydantic import TypeAdapter +from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmissionEvaluationRequest, + SelectedPostSubmissionEvaluationResult, +) + + +PostSubmissionExecutionRequest: TypeAlias = SelectedPostSubmissionEvaluationRequest +PostSubmissionExecutionResult: TypeAlias = SelectedPostSubmissionEvaluationResult + +_REQUEST = TypeAdapter(PostSubmissionExecutionRequest) +_RESULT = TypeAdapter(PostSubmissionExecutionResult) + + +def validate_execution_request(value: object) -> PostSubmissionExecutionRequest: + """Validate the only executable POST request schema.""" + return _REQUEST.validate_python(value) + + +def validate_stored_execution_request(value: str) -> PostSubmissionExecutionRequest: + """Decode selected canonical JSON using the strict model boundary.""" + return _REQUEST.validate_json(value) + + +def validate_execution_result(value: object) -> PostSubmissionExecutionResult: + """Validate the only executable POST result schema.""" + return _RESULT.validate_python(value) + + +def validate_stored_execution_result(value: str) -> PostSubmissionExecutionResult: + """Decode selected canonical result JSON through the strict model boundary.""" + return _RESULT.validate_json(value) + + +__all__ = ( + "PostSubmissionExecutionRequest", + "PostSubmissionExecutionResult", + "validate_execution_request", + "validate_stored_execution_request", + "validate_stored_execution_result", + "validate_execution_result", +) diff --git a/backend/app/modules/checkers/api/execution.py b/backend/app/modules/checkers/api/execution.py index 820bbe569..60b4dd540 100644 --- a/backend/app/modules/checkers/api/execution.py +++ b/backend/app/modules/checkers/api/execution.py @@ -10,13 +10,14 @@ from pydantic import AwareDatetime, Field, field_validator, model_validator from app.core.hashing import canonical_json_hash -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, - PostSubmitCurrentResultReference, + +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, + PostSubmissionExecutionResult, ) -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitValue, +from app.modules.checkers.api.selected_post_submit import SelectedPostSubmitCurrentResultReference +from app.modules.checkers.api.canonical_values import ( + CheckerValue, ResourceId, Sha256, VersionNumber, @@ -34,7 +35,7 @@ class CheckerRequestConflict(CheckerExecutionUnavailable): """A request key or generation already belongs to different facts.""" -class CheckerRetryCommand(PostSubmitValue): +class CheckerRetryCommand(CheckerValue): """Operator's exact source, reason and replay key; no caller-selected successor.""" source_run_id: ResourceId @@ -49,7 +50,7 @@ def nonblank_reason(cls, value: str) -> str: return value -class CheckerRetrySource(PostSubmitValue): +class CheckerRetrySource(CheckerValue): """Stored immutable failed-run tuple presented to AUTH before supersession.""" project_id: ResourceId @@ -67,7 +68,7 @@ class CheckerRetrySource(PostSubmitValue): failure_code: str = Field(min_length=1, max_length=100) -class CheckerRetryPreparation(PostSubmitValue): +class CheckerRetryPreparation(CheckerValue): """Stable actor/request/source identity bound before AUTH grants are locked.""" actor_profile_id: ResourceId @@ -83,7 +84,7 @@ def nonblank_reason(cls, value: str) -> str: return value -class CheckerRetrySuccessor(PostSubmitValue): +class CheckerRetrySuccessor(CheckerValue): """Actual coordinator and participant identities appended in one transaction.""" request_id: ResourceId @@ -95,7 +96,7 @@ class CheckerRetrySuccessor(PostSubmitValue): audit_event_id: ResourceId -class CheckerRetryResult(PostSubmitValue): +class CheckerRetryResult(CheckerValue): """Retained receipt identities returned on new request or authorized replay.""" receipt_id: ResourceId @@ -104,7 +105,7 @@ class CheckerRetryResult(PostSubmitValue): authorization_decision_id: ResourceId -class CheckerRetryEventExpectation(PostSubmitValue): +class CheckerRetryEventExpectation(CheckerValue): """Immutable retry event facts for exact TASK delivery comparison.""" event_type: str @@ -118,14 +119,14 @@ class CheckerRetryEventExpectation(PostSubmitValue): payload_digest: Sha256 -class CheckerRetryDelivery(PostSubmitValue): +class CheckerRetryDelivery(CheckerValue): """CHECKERS-owned committed source and exact event for the single TASK handler.""" - request: PostSubmissionEvaluationRequest + request: PostSubmissionExecutionRequest expected_event: CheckerRetryEventExpectation -class CheckerRetryAuthorityReceipt(PostSubmitValue): +class CheckerRetryAuthorityReceipt(CheckerValue): """AUTH's consumed exact resource, used only in the caller transaction.""" actor_profile_id: ResourceId @@ -169,7 +170,7 @@ async def record( ... -class EvaluationReservation(PostSubmitValue): +class EvaluationReservation(CheckerValue): """Stable request, attempt and result identities reserved together.""" request_id: ResourceId @@ -179,14 +180,14 @@ class EvaluationReservation(PostSubmitValue): evaluation_generation: VersionNumber -class ReservedEvaluation(PostSubmitValue): +class ReservedEvaluation(CheckerValue): """Exact retained request and identities, without a currentness assertion.""" - request: PostSubmissionEvaluationRequest + request: PostSubmissionExecutionRequest reservation: EvaluationReservation -class ExecutionLease(PostSubmitValue): +class ExecutionLease(CheckerValue): """Database-timed generation granting temporary use of one reserved attempt.""" reservation: EvaluationReservation @@ -195,14 +196,14 @@ class ExecutionLease(PostSubmitValue): expires_at: AwareDatetime -class ExecuteFacts(PostSubmitValue): +class ExecuteFacts(CheckerValue): """Exact request and lease consumed by execution authority.""" - request: PostSubmissionEvaluationRequest + request: PostSubmissionExecutionRequest lease: ExecutionLease -class VerifiedMaterialFacts(PostSubmitValue): +class VerifiedMaterialFacts(CheckerValue): """ART-verified byte and lineage facts required for completed results.""" submission_id: ResourceId @@ -219,7 +220,7 @@ class VerifiedMaterialFacts(PostSubmitValue): class FinalizeFacts(ExecuteFacts): """Exact terminal result and material custody consumed by finalization authority.""" - result: PostSubmissionEvaluationResult + result: PostSubmissionExecutionResult material: VerifiedMaterialFacts | None input_materialization_evidence_id: ResourceId | None output_binding_ids: tuple[ResourceId, ...] = Field(max_length=0) @@ -291,14 +292,14 @@ def execution_authority_digest(facts: ExecuteFacts | FinalizeAuthorityFacts) -> return canonical_json_hash(execution_authority_values(facts)) -class ExecuteEvidence(PostSubmitValue): +class ExecuteEvidence(CheckerValue): """Opaque action-specific receipt; real AUTH custody is installed by 04D.""" evidence_id: ResourceId facts_digest: Sha256 -class FinalizeEvidence(PostSubmitValue): +class FinalizeEvidence(CheckerValue): """Nominally distinct from execute evidence, even for the same run.""" evidence_id: ResourceId @@ -344,12 +345,12 @@ async def require_current_execution(self, facts: ExecuteFacts) -> None: class ExecutionAuthorityPort(Protocol): """Provide fresh authorization before acquiring an execution lease.""" - async def preflight(self, request: PostSubmissionEvaluationRequest) -> None: + async def preflight(self, request: PostSubmissionExecutionRequest) -> None: """Reject unavailable execution authority before entering preparation.""" ... def prepare_execution( - self, request: PostSubmissionEvaluationRequest + self, request: PostSubmissionExecutionRequest ) -> AbstractAsyncContextManager[PreparedExecution]: """Hold execution preparation within the caller transaction.""" ... @@ -358,37 +359,37 @@ def prepare_execution( class FinalizationAuthorityPort(Protocol): """Provide fresh authorization for atomic terminal publication.""" - async def preflight(self, request: PostSubmissionEvaluationRequest) -> None: + async def preflight(self, request: PostSubmissionExecutionRequest) -> None: """Reject unavailable finalization authority before preparation.""" ... def prepare_finalization( - self, request: PostSubmissionEvaluationRequest + self, request: PostSubmissionExecutionRequest ) -> AbstractAsyncContextManager[PreparedFinalization]: """Hold finalization preparation within the terminal transaction.""" ... -class CompletedEvaluation(PostSubmitValue): +class CompletedEvaluation(CheckerValue): """Fresh owner projection; the consumer must keep the caller transaction open.""" - reference: PostSubmitCurrentResultReference + reference: SelectedPostSubmitCurrentResultReference routing_recommendation: Literal["allow_review", "needs_revision", "task_setup_blocked"] - result: PostSubmissionEvaluationResult + result: PostSubmissionExecutionResult class EvaluationTaskGuard(Protocol): """TASK locks exact evaluation scope; the boolean is reservation eligibility, not authority.""" - async def lock_evaluation_scope(self, request: PostSubmissionEvaluationRequest) -> bool: + async def lock_evaluation_scope(self, request: PostSubmissionExecutionRequest) -> bool: """Hold TASK locks through commit; False forbids new reservations, not current reads.""" ... - async def lock_terminal_retry_scope(self, request: PostSubmissionEvaluationRequest) -> None: + async def lock_terminal_retry_scope(self, request: PostSubmissionExecutionRequest) -> None: """Lock the active, latest evaluation-pending TASK scope for a new retry.""" ... - async def lock_retry_replay_scope(self, request: PostSubmissionEvaluationRequest) -> None: + async def lock_retry_replay_scope(self, request: PostSubmissionExecutionRequest) -> None: """Lock exact historical receipt owners without current lifecycle eligibility.""" ... @@ -417,39 +418,39 @@ async def require_current_completion( ... async def reserve_current_evaluation( - self, request: PostSubmissionEvaluationRequest + self, request: PostSubmissionExecutionRequest ) -> EvaluationReservation: """Reserve or replay the exact request without committing the caller transaction.""" ... async def read_current_result( - self, request: PostSubmissionEvaluationRequest + self, request: PostSubmissionExecutionRequest ) -> CompletedEvaluation: """Lock and return the completed evaluation for the exact current request.""" ... -class EvaluationCompletion(PostSubmitValue): +class EvaluationCompletion(CheckerValue): """Bounded publication facts, never perpetual authority or private packet data.""" project_id: ResourceId task_id: ResourceId submission_id: ResourceId - reference: PostSubmitCurrentResultReference + reference: SelectedPostSubmitCurrentResultReference routing_recommendation: Literal["allow_review", "needs_revision", "task_setup_blocked"] output_binding_ids: tuple[ResourceId, ...] = Field(max_length=0) execute_evidence_id: ResourceId finalize_evidence_id: ResourceId -class VerifiedEvaluationCompletion(PostSubmitValue): +class VerifiedEvaluationCompletion(CheckerValue): """Current owner-verified completion and retained material, never routing authority.""" completion: EvaluationCompletion submission_version: VersionNumber material: VerifiedMaterialFacts input_materialization_evidence_id: ResourceId - result: PostSubmissionEvaluationResult + result: PostSubmissionExecutionResult @model_validator(mode="after") def bind_completed_result(self): diff --git a/backend/app/modules/checkers/api/external.py b/backend/app/modules/checkers/api/external.py index 0a0f0cb61..85e0c72c4 100644 --- a/backend/app/modules/checkers/api/external.py +++ b/backend/app/modules/checkers/api/external.py @@ -33,6 +33,7 @@ StrictStr, Field(min_length=1, max_length=50, pattern=r"^[a-z0-9][a-z0-9_.-]*$") ] ExternalCheckerPhase = Literal["pre_submit", "post_submit"] +EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS = 60_000 PRE_SUBMIT_INPUT_SCHEMA_ID = "external_checker_pre_submit_input" POST_SUBMIT_INPUT_SCHEMA_ID = "external_checker_post_submit_input" @@ -67,13 +68,15 @@ def _canonical_json(value: object) -> str: """Match PostgreSQL JSONB canonical scalars without changing shared hashes.""" if isinstance(value, Mapping): if any(type(key) is not str or "\x00" in key for key in value): - raise ExternalCheckerContractError( - "external checker value is not canonical JSON" + raise ExternalCheckerContractError("external checker value is not canonical JSON") + return ( + "{" + + ",".join( + f"{json.dumps(key, ensure_ascii=False)}:{_canonical_json(item)}" + for key, item in sorted(value.items()) ) - return "{" + ",".join( - f"{json.dumps(key, ensure_ascii=False)}:{_canonical_json(item)}" - for key, item in sorted(value.items()) - ) + "}" + + "}" + ) if isinstance(value, (list, tuple)): return "[" + ",".join(_canonical_json(item) for item in value) + "]" if value is None: @@ -84,17 +87,13 @@ def _canonical_json(value: object) -> str: return str(value) if type(value) is float: if not isfinite(value): - raise ExternalCheckerContractError( - "external checker value is not canonical JSON" - ) + raise ExternalCheckerContractError("external checker value is not canonical JSON") if value == 0.0: return "0.0" return format(Decimal(str(value)), "f") if type(value) is str: if "\x00" in value: - raise ExternalCheckerContractError( - "external checker value is not canonical JSON" - ) + raise ExternalCheckerContractError("external checker value is not canonical JSON") return json.dumps(value, ensure_ascii=False) raise ExternalCheckerContractError("external checker value is not canonical JSON") @@ -215,9 +214,7 @@ class ExternalCheckerRegistryEntry(ExternalCheckerRegistrySpec): def validate_registry_entry_id(cls, value: UUID) -> UUID: """Require the server-selected registry identity to be canonical UUIDv7.""" if value.version != 7 or value.variant != RFC_4122: - raise ExternalCheckerContractError( - "external checker registry entry ID must be UUIDv7" - ) + raise ExternalCheckerContractError("external checker registry entry ID must be UUIDv7") return value @model_validator(mode="after") @@ -255,9 +252,7 @@ class ExternalCheckerRegistrationRequest(ExternalCheckerValue): def validate_registry_entry_id(cls, value: UUID) -> UUID: """Require the requested durable identity to be canonical UUIDv7.""" if value.version != 7 or value.variant != RFC_4122: - raise ExternalCheckerContractError( - "external checker registry entry ID must be UUIDv7" - ) + raise ExternalCheckerContractError("external checker registry entry ID must be UUIDv7") return value @model_validator(mode="after") @@ -310,16 +305,116 @@ async def read_exact( ) -> ExternalCheckerRegistryEntry: """Read one exact immutable entry without selecting a latest version.""" + async def read_identity( + self, *, capability_id: str, capability_version: str, phase: ExternalCheckerPhase + ) -> ExternalCheckerRegistryEntry: + """Resolve the one immutable registered phase identity during approval.""" + + async def list_entries(self) -> tuple[ExternalCheckerRegistryEntry, ...]: + """Return every immutable identity in canonical phase/name/version order.""" + + +class ExternalCheckerCapabilityDefinition(ExternalCheckerValue): + """One model-facing capability backed by code or an immutable registry row.""" + + capability_id: Identifier + capability_version: SchemaVersion + phase: ExternalCheckerPhase + selectable: bool + registry_entry: ExternalCheckerRegistryEntry | None = None + + @model_validator(mode="after") + def validate_source(self) -> Self: + """Keep the sole code-owned default distinct from selectable project images.""" + if self.selectable != (self.registry_entry is not None): + raise ExternalCheckerContractError("external checker capability source is invalid") + if self.registry_entry is not None and ( + self.capability_id, + self.capability_version, + self.phase, + ) != ( + self.registry_entry.capability_id, + self.registry_entry.capability_version, + self.registry_entry.phase, + ): + raise ExternalCheckerContractError("external checker capability identity is invalid") + return self + + +class ExternalCheckerCapabilityProjection(ExternalCheckerValue): + """Complete immutable compiler input with one default and registered images.""" + + schema_version: Literal["external_checker_capabilities.v1"] = "external_checker_capabilities.v1" + definitions: tuple[ExternalCheckerCapabilityDefinition, ...] + manifest_sha256: Sha256 + + @model_validator(mode="after") + def validate_projection(self) -> Self: + identities = [ + (item.phase, item.capability_id, item.capability_version) for item in self.definitions + ] + if identities != sorted(identities) or len(identities) != len(set(identities)): + raise ExternalCheckerContractError( + "external checker capability projection is not canonical" + ) + defaults = [item for item in self.definitions if not item.selectable] + if len(defaults) != 1 or ( + defaults[0].capability_id, + defaults[0].capability_version, + defaults[0].phase, + ) != ("workstream.default", "v1", "pre_submit"): + raise ExternalCheckerContractError("external checker default capability is invalid") + body = self.model_dump(mode="json", exclude={"manifest_sha256"}) + if self.manifest_sha256 != external_checker_json_hash(body): + raise ExternalCheckerContractError( + "external checker capability projection digest mismatch" + ) + return self + -class ExternalCheckerMaterial(ExternalCheckerValue): - """One verified read-only material identity without provider coordinates.""" +class PreSubmitExternalCheckerMaterial(ExternalCheckerValue): + """Verified prepared bytes that exist before a Submission or ART binding.""" + phase: Literal["pre_submit"] = "pre_submit" role: Identifier + prepared_generation_id: UUID = Field(strict=True) + attempt_id: UUID = Field(strict=True) + attempt_request_digest: Sha256 + archive_sha256: Sha256 + archive_byte_count: Annotated[StrictInt, Field(ge=0, le=9_223_372_036_854_775_807)] + semantic_manifest_sha256: Sha256 + media_type: Annotated[ + StrictStr, Field(min_length=1, max_length=255, pattern=r"^[^\s/]+/[^\s/]+$") + ] + + +class PostSubmitExternalCheckerMaterial(ExternalCheckerValue): + """Verified native Submission and ART ancestry selected for POST execution.""" + + phase: Literal["post_submit"] = "post_submit" + role: Identifier + submission_id: UUID = Field(strict=True) + submission_version: Annotated[StrictInt, Field(ge=1)] + binding_id: UUID = Field(strict=True) content_id: UUID = Field(strict=True) replica_id: UUID = Field(strict=True) - sha256: Sha256 + content_sha256: Sha256 byte_count: Annotated[StrictInt, Field(ge=0, le=9_223_372_036_854_775_807)] - media_type: Annotated[StrictStr, Field(min_length=1, max_length=255, pattern=r"^[^\s/]+/[^\s/]+$")] + media_type: Annotated[ + StrictStr, Field(min_length=1, max_length=255, pattern=r"^[^\s/]+/[^\s/]+$") + ] + evidence_id: UUID = Field(strict=True) + verification_receipt_id: UUID = Field(strict=True) + verification_job_id: UUID = Field(strict=True) + verification_generation: Annotated[StrictInt, Field(ge=1)] + namespace_fingerprint: Sha256 + semantic_manifest_sha256: Sha256 + + +ExternalCheckerMaterial = Annotated[ + PreSubmitExternalCheckerMaterial | PostSubmitExternalCheckerMaterial, + Field(discriminator="phase"), +] class PreSubmitExternalCheckerIdentity(ExternalCheckerValue): @@ -363,16 +458,14 @@ class PostSubmitExternalCheckerIdentity(ExternalCheckerValue): class ExternalCheckerExecutionRequest(ExternalCheckerValue): """One bounded normalized invocation selected by a later owner operation.""" - schema_version: Literal["external_checker_request.v1"] = ( - EXTERNAL_CHECKER_REQUEST_SCHEMA_VERSION - ) + schema_version: Literal["external_checker_request.v1"] = EXTERNAL_CHECKER_REQUEST_SCHEMA_VERSION registry: ExternalCheckerRegistryEntry identity: ExternalCheckerExecutionIdentity configuration: dict[str, JsonValue] configuration_sha256: Sha256 input: dict[str, JsonValue] input_sha256: Sha256 - materials: tuple[ExternalCheckerMaterial, ...] = Field(max_length=32) + materials: tuple[ExternalCheckerMaterial, ...] = Field(min_length=1, max_length=32) request_digest: Sha256 @model_validator(mode="after") @@ -382,10 +475,36 @@ def validate_request(self) -> Self: raise ExternalCheckerContractError("external checker request phase mismatch") if len({item.role for item in self.materials}) != len(self.materials): raise ExternalCheckerContractError("external checker material role is duplicated") + if any(item.phase != self.identity.phase for item in self.materials): + raise ExternalCheckerContractError("external checker material phase mismatch") + if self.identity.phase == "pre_submit": + for material in self.materials: + if not isinstance(material, PreSubmitExternalCheckerMaterial) or ( + material.prepared_generation_id, + material.attempt_id, + material.attempt_request_digest, + ) != ( + self.identity.prepared_generation_id, + self.identity.attempt_id, + self.identity.attempt_request_digest, + ): + raise ExternalCheckerContractError( + "external checker prepared material identity mismatch" + ) + else: + for material in self.materials: + if not isinstance(material, PostSubmitExternalCheckerMaterial) or ( + material.submission_id, + material.submission_version, + ) != ( + self.identity.submission_id, + self.identity.submission_version, + ): + raise ExternalCheckerContractError( + "external checker submission material identity mismatch" + ) if self.configuration_sha256 != external_checker_json_hash(self.configuration): - raise ExternalCheckerContractError( - "external checker configuration digest mismatch" - ) + raise ExternalCheckerContractError("external checker configuration digest mismatch") if self.input_sha256 != external_checker_json_hash(self.input): raise ExternalCheckerContractError("external checker input digest mismatch") if len(_canonical_bytes(self.configuration)) > MAX_CONFIGURATION_BYTES: @@ -412,9 +531,7 @@ class ExternalCheckerFinding(ExternalCheckerValue): class ExternalCheckerExecutionResult(ExternalCheckerValue): """Normalized completed or infrastructure result bound to one exact request.""" - schema_version: Literal["external_checker_result.v1"] = ( - EXTERNAL_CHECKER_RESULT_SCHEMA_VERSION - ) + schema_version: Literal["external_checker_result.v1"] = EXTERNAL_CHECKER_RESULT_SCHEMA_VERSION request_digest: Sha256 registry_entry_id: UUID = Field(strict=True) registry_entry_digest: Sha256 @@ -438,7 +555,11 @@ class ExternalCheckerExecutionResult(ExternalCheckerValue): def validate_result(self) -> Self: """Keep work verdicts distinct from bounded infrastructure outcomes.""" if self.outcome == "infrastructure_failed": - if self.verdict is not None or self.findings or self.infrastructure_failure_code is None: + if ( + self.verdict is not None + or self.findings + or self.infrastructure_failure_code is None + ): raise ExternalCheckerContractError( "external checker infrastructure result shape is invalid" ) @@ -449,9 +570,7 @@ def validate_result(self) -> Self: ) has_error = any(item.level == "error" for item in self.findings) if (self.verdict == "failed") != has_error: - raise ExternalCheckerContractError( - "external checker verdict and findings disagree" - ) + raise ExternalCheckerContractError("external checker verdict and findings disagree") body = self.model_dump(mode="json", exclude={"result_digest"}) if len(_canonical_bytes(body)) > MAX_RESULT_BYTES: raise ExternalCheckerContractError("external checker result is too large") @@ -530,15 +649,11 @@ def make_external_checker_execution_request( **fields: object, ) -> ExternalCheckerExecutionRequest: """Build one external execution request with a derived canonical digest.""" - return _make_derived( - ExternalCheckerExecutionRequest, _REQUEST_FIELDS, "request_digest", fields - ) + return _make_derived(ExternalCheckerExecutionRequest, _REQUEST_FIELDS, "request_digest", fields) def make_external_checker_execution_result( **fields: object, ) -> ExternalCheckerExecutionResult: """Build one normalized result with a derived canonical digest.""" - return _make_derived( - ExternalCheckerExecutionResult, _RESULT_FIELDS, "result_digest", fields - ) + return _make_derived(ExternalCheckerExecutionResult, _RESULT_FIELDS, "result_digest", fields) diff --git a/backend/app/modules/checkers/api/external_policy.py b/backend/app/modules/checkers/api/external_policy.py new file mode 100644 index 000000000..651e51f9a --- /dev/null +++ b/backend/app/modules/checkers/api/external_policy.py @@ -0,0 +1,334 @@ +"""Locked external-checker policies compiled from approved guide bindings.""" + +from __future__ import annotations + +import json +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from app.core.hashing import canonical_json_hash +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + WORKSTREAM_DEFAULT_CHECKER_ID, + WORKSTREAM_DEFAULT_CHECKER_VERSION, +) +from app.modules.checkers.api.external import ( + EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS, + ExternalCheckerPhase, + ExternalCheckerRegistryEntry, + ExternalCheckerRegistryPort, + external_checker_json_hash, +) + + +WORKSTREAM_DEFAULT_EXECUTION_BUDGET_MS = 60_000 +MAXIMUM_SELECTED_PRE_EXECUTION_BUDGET_MS = 7_200_000 +SELECTED_POST_MATERIALIZATION_BUDGET_MS = 60_000 +# The outbox claim is natively bounded to one hour. Keep explicit time for the +# request reader/authority prelude, execution finalization and delivery commit. +MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS = 3_400_000 +SELECTED_POST_HANDLER_PRELUDE_MARGIN_MS = 60_000 +SELECTED_POST_FINALIZATION_MARGIN_MS = 60_000 +SELECTED_POST_HANDLER_TIMEOUT_SECONDS = ( + MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS + + SELECTED_POST_HANDLER_PRELUDE_MARGIN_MS + + SELECTED_POST_FINALIZATION_MARGIN_MS +) // 1000 +SELECTED_POST_DELIVERY_LEASE_SECONDS = 3_600 + + +class ExternalCheckerPolicyError(ValueError): + """Reject incomplete, stale or phase-substituted locked checker policy.""" + + +class _PolicyValue(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, revalidate_instances="always") + + +class LockedExternalCheckerBinding(_PolicyValue): + """One requirement bound to an exact immutable registry publication.""" + + requirement_id: str = Field(min_length=1, max_length=100) + stage: ExternalCheckerPhase + registry: ExternalCheckerRegistryEntry + configuration: dict[str, JsonValue] + configuration_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + 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) + if self.configuration_sha256 != external_checker_json_hash(self.configuration): + raise ExternalCheckerPolicyError("checker binding configuration mismatch") + return self + + +class LockedWorkstreamDefault(_PolicyValue): + """Code-owned default identity included in every approved pre policy.""" + + checker_id: Literal[WORKSTREAM_DEFAULT_CHECKER_ID] = WORKSTREAM_DEFAULT_CHECKER_ID + checker_version: Literal[WORKSTREAM_DEFAULT_CHECKER_VERSION] = ( + WORKSTREAM_DEFAULT_CHECKER_VERSION + ) + behavior_sha256: str = Field( + default=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + pattern=r"^sha256:[0-9a-f]{64}$", + ) + + @model_validator(mode="after") + def validate_behavior(self) -> Self: + """Bind the locked identity to the executable code-owned manifest.""" + if self.behavior_sha256 != WORKSTREAM_DEFAULT_BEHAVIOR_SHA256: + raise ExternalCheckerPolicyError("default checker behavior mismatch") + return self + + +class LockedExternalPreSubmitPolicy(_PolicyValue): + """One default followed by every required project pre-submit image.""" + + schema_version: Literal["external_pre_submit_policy.v1"] = "external_pre_submit_policy.v1" + workstream_default: LockedWorkstreamDefault = Field(default_factory=LockedWorkstreamDefault) + entries: tuple[LockedExternalCheckerBinding, ...] + policy_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def validate_policy(self) -> Self: + _validate_entries(self.entries, "pre_submit") + if self.policy_sha256 != canonical_json_hash( + self.model_dump(mode="json", exclude={"policy_sha256"}) + ): + raise ExternalCheckerPolicyError("pre-submit policy digest mismatch") + selected_pre_submit_execution_budget_ms( + self, artifact_deadline_ms=MAXIMUM_SELECTED_PRE_EXECUTION_BUDGET_MS + ) + return self + + +class LockedExternalPostSubmitPolicy(_PolicyValue): + """Every required post-submit image; checkers retain no acceptance authority.""" + + schema_version: Literal["external_post_submit_policy.v1"] = "external_post_submit_policy.v1" + entries: tuple[LockedExternalCheckerBinding, ...] + policy_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def validate_policy(self) -> Self: + _validate_entries(self.entries, "post_submit") + if self.policy_sha256 != canonical_json_hash( + self.model_dump(mode="json", exclude={"policy_sha256"}) + ): + raise ExternalCheckerPolicyError("post-submit policy digest mismatch") + # Approval, admission and dispatch share this closed runnable policy. + selected_post_submit_execution_budget_ms(self) + return self + + +class LockedExternalCheckerPolicies(_PolicyValue): + """Complete approval-time pre/post policy custody for one guide generation.""" + + schema_version: Literal["external_checker_policies.v1"] = "external_checker_policies.v1" + pre_submit: LockedExternalPreSubmitPolicy + post_submit: LockedExternalPostSubmitPolicy + policies_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def validate_policies(self) -> Self: + if self.policies_sha256 != canonical_json_hash( + self.model_dump(mode="json", exclude={"policies_sha256"}) + ): + raise ExternalCheckerPolicyError("checker policies digest mismatch") + return self + + +def validate_stored_external_checker_policies( + value: object, +) -> LockedExternalCheckerPolicies: + """Decode retained JSON through strict UUID and timestamp model boundaries.""" + return LockedExternalCheckerPolicies.model_validate_json( + json.dumps(value, ensure_ascii=False, allow_nan=False) + ) + + +def validate_stored_external_pre_policy(value: object) -> LockedExternalPreSubmitPolicy: + """Decode one retained selected PRE policy without Python-string coercion.""" + return LockedExternalPreSubmitPolicy.model_validate_json( + json.dumps(value, ensure_ascii=False, allow_nan=False) + ) + + +def validate_stored_external_post_policy(value: object) -> LockedExternalPostSubmitPolicy: + """Decode one retained selected POST policy without Python-string coercion.""" + return LockedExternalPostSubmitPolicy.model_validate_json( + json.dumps(value, ensure_ascii=False, allow_nan=False) + ) + + +def _validate_entries( + entries: tuple[LockedExternalCheckerBinding, ...], phase: ExternalCheckerPhase +) -> None: + identities = [ + ( + item.requirement_id, + item.registry.capability_id, + item.registry.capability_version, + ) + for item in entries + ] + if any(item.stage != phase for item in entries) or identities != sorted(identities): + raise ExternalCheckerPolicyError("checker policy order is not canonical") + if len(identities) != len(set(identities)): + raise ExternalCheckerPolicyError("checker policy binding is duplicated") + + +async def compile_external_checker_policies( + result: object, + registry: ExternalCheckerRegistryPort, +) -> tuple[LockedExternalPreSubmitPolicy, LockedExternalPostSubmitPolicy]: + """Resolve every supported binding to exact immutable registry custody.""" + from app.interfaces.project_agents import ProjectGuideCompilationResult + + result = ProjectGuideCompilationResult.model_validate(result) + if result.status == "guide_blocked": + raise ExternalCheckerPolicyError("checker capability coverage is incomplete") + 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") + return _make_policy(LockedExternalPreSubmitPolicy, pre), _make_policy( + LockedExternalPostSubmitPolicy, post + ) + + +async def compile_external_checker_policy_bundle( + result: object, + registry: ExternalCheckerRegistryPort, +) -> LockedExternalCheckerPolicies: + """Lock both execution phases into one immutable approval-time value.""" + pre, post = await compile_external_checker_policies(result, registry) + body = { + "schema_version": "external_checker_policies.v1", + "pre_submit": pre.model_dump(mode="json"), + "post_submit": post.model_dump(mode="json"), + } + return LockedExternalCheckerPolicies( + pre_submit=pre, + post_submit=post, + policies_sha256=canonical_json_hash(body), + ) + + +async def _resolve(bindings, requirements, registry, phase): + from app.interfaces.project_agents import ( + RequirementDisposition, + ) + + expected = ( + RequirementDisposition.SUPPORTED_PRE_SUBMIT + if phase == "pre_submit" + else RequirementDisposition.SUPPORTED_POST_SUBMIT + ) + resolved = [] + for binding in sorted( + bindings, + key=lambda item: (item.requirement_id, item.capability_id, item.capability_version), + ): + if ( + requirements.get(binding.requirement_id) is None + or requirements[binding.requirement_id].disposition is not expected + or binding.stage.value != phase + ): + raise ExternalCheckerPolicyError("checker binding does not cover requirement") + entry = await registry.read_identity( + capability_id=binding.capability_id, + capability_version=binding.capability_version, + phase=phase, + ) + if ( + entry.capability_id != binding.capability_id + or entry.capability_version != binding.capability_version + or entry.phase != phase + ): + raise ExternalCheckerPolicyError("checker registry identity mismatch") + configuration = { + parameter.name: parameter.model_dump(mode="json")["value"] + for parameter in binding.parameters + } + resolved.append( + LockedExternalCheckerBinding( + requirement_id=binding.requirement_id, + stage=phase, + registry=entry, + configuration=configuration, + configuration_sha256=external_checker_json_hash(configuration), + ) + ) + expected_ids = { + item.requirement_id for item in requirements.values() if item.disposition is expected + } + if {item.requirement_id for item in resolved} != expected_ids: + raise ExternalCheckerPolicyError("checker requirement coverage is incomplete") + return tuple(resolved) + + +def _make_policy(model, entries): + hash_body = { + "schema_version": model.model_fields["schema_version"].default, + "entries": [item.model_dump(mode="json") for item in entries], + } + if model is LockedExternalPreSubmitPolicy: + default = LockedWorkstreamDefault() + hash_body["workstream_default"] = default.model_dump(mode="json") + return model( + workstream_default=default, + entries=entries, + policy_sha256=canonical_json_hash(hash_body), + ) + return model(entries=entries, policy_sha256=canonical_json_hash(hash_body)) + + +def selected_pre_submit_execution_budget_ms( + policy: LockedExternalPreSubmitPolicy, + *, + artifact_deadline_ms: int, +) -> int: + """Return the exact ordered work budget only when ART can keep custody alive.""" + if ( + type(policy) is not LockedExternalPreSubmitPolicy + or type(artifact_deadline_ms) is not int + or artifact_deadline_ms <= 0 + or artifact_deadline_ms > MAXIMUM_SELECTED_PRE_EXECUTION_BUDGET_MS + ): + raise ExternalCheckerPolicyError("selected PRE execution budget is invalid") + required = WORKSTREAM_DEFAULT_EXECUTION_BUDGET_MS + sum( + binding.registry.resources.deadline_ms + EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS + for binding in policy.entries + ) + if required > artifact_deadline_ms: + raise ExternalCheckerPolicyError("selected PRE execution exceeds ART custody deadline") + return required + + +def selected_post_submit_execution_budget_ms( + policy: LockedExternalPostSubmitPolicy, + *, + artifact_deadline_ms: int | None = None, +) -> int: + """Bound ART materialization and every ordered POST image under one lease.""" + if ( + type(policy) is not LockedExternalPostSubmitPolicy + or artifact_deadline_ms is not None + and (type(artifact_deadline_ms) is not int or artifact_deadline_ms <= 0) + ): + raise ExternalCheckerPolicyError("selected POST execution budget is invalid") + required = SELECTED_POST_MATERIALIZATION_BUDGET_MS + sum( + binding.registry.resources.deadline_ms + EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS + for binding in policy.entries + ) + if required > MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS: + raise ExternalCheckerPolicyError("selected POST execution exceeds durable worker deadline") + if artifact_deadline_ms is not None and required > artifact_deadline_ms: + raise ExternalCheckerPolicyError("selected POST execution exceeds ART custody deadline") + return required diff --git a/backend/app/modules/checkers/api/history.py b/backend/app/modules/checkers/api/history.py index 93e5ec912..e97e1fec1 100644 --- a/backend/app/modules/checkers/api/history.py +++ b/backend/app/modules/checkers/api/history.py @@ -7,7 +7,7 @@ CheckerStatus = Literal["queued", "running", "completed", "infrastructure_failed"] CheckerResultStatus = Literal["passed", "warning", "failed"] -CheckerSeverity = Literal["info", "low", "medium", "high", "critical"] +CheckerSeverity = Literal["info", "low", "medium", "high", "critical", "error"] CheckerRoutingRecommendation = Literal["not_evaluated", "allow_review", "needs_revision", "task_setup_blocked"] CheckerOutcomeSource = Literal["none", "auto_checker"] diff --git a/backend/app/modules/checkers/api/material.py b/backend/app/modules/checkers/api/material.py new file mode 100644 index 000000000..fea248eee --- /dev/null +++ b/backend/app/modules/checkers/api/material.py @@ -0,0 +1,18 @@ +"""Detached verified material metadata shared by PRE and POST contracts.""" + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True, slots=True) +class SubmissionMaterialEntry: + """Expose verified archive metadata without a filesystem path.""" + + normalized_path: str + entry_type: Literal["file", "directory"] + byte_count: int + sha256: str | None + executable: bool | None + + +__all__ = ("SubmissionMaterialEntry",) diff --git a/backend/app/modules/checkers/api/materialization.py b/backend/app/modules/checkers/api/materialization.py index 10812ca4c..d6246febf 100644 --- a/backend/app/modules/checkers/api/materialization.py +++ b/backend/app/modules/checkers/api/materialization.py @@ -2,17 +2,22 @@ from dataclasses import dataclass from abc import ABC, abstractmethod -from contextlib import AbstractAsyncContextManager -from typing import Literal, Protocol +from contextlib import AbstractAsyncContextManager, AbstractContextManager +from typing import Protocol from uuid import UUID from app.core.hashing import canonical_json_hash from app.modules.checkers.api.execution import ExecuteFacts, execution_authority_digest, VerifiedMaterialFacts -from app.modules.checkers.api.post_submit_catalogue import PostSubmitValue, ResourceId, Sha256, VersionNumber +from app.modules.checkers.api.material import SubmissionMaterialEntry +from app.modules.checkers.api.canonical_values import CheckerValue, ResourceId, Sha256, VersionNumber -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, + PostSubmissionExecutionResult, +) +from app.modules.checkers.api.external import ( + ExternalCheckerExecutionRequest, + PostSubmitExternalCheckerMaterial, ) @@ -24,7 +29,7 @@ class PostSubmissionMaterializationFailure(RuntimeError): """Retain a known post-authorization material failure after successful cleanup.""" -class MaterializationFacts(PostSubmitValue): +class MaterializationFacts(CheckerValue): """Exact selected ancestry without provider coordinates or scratch authority.""" execution: ExecuteFacts @@ -84,16 +89,6 @@ def prepare_materialization(self, facts: ExecuteFacts) -> AbstractAsyncContextMa ... -@dataclass(frozen=True, slots=True) -class SubmissionMaterialEntry: - """Expose verified archive metadata without a filesystem path.""" - normalized_path: str - entry_type: Literal["file", "directory"] - byte_count: int - sha256: str | None - executable: bool | None - - class SubmissionMaterialView(Protocol): """Permit bounded reads only during the consumer callback.""" @@ -104,12 +99,28 @@ def entries(self) -> tuple[SubmissionMaterialEntry, ...]: def read_file(self, normalized_path: str, *, maximum_bytes: int) -> bytes: """Read one bounded verified file without filesystem authority.""" + def external_checker_grant( + self, request: ExternalCheckerExecutionRequest + ) -> AbstractContextManager["ExternalCheckerGrantReference"]: + """Issue one request-bound opaque grant for this callback lifetime only.""" + + @property + def external_checker_material(self) -> PostSubmitExternalCheckerMaterial: + """Return detached exact POST custody without provider or path coordinates.""" + + +class ExternalCheckerGrantReference(Protocol): + """Opaque ART grant identity accepted by the trusted service adapter.""" + + grant_id: str + binding_digest: str + class PostSubmissionMaterialConsumer(Protocol): """Evaluate scoped input asynchronously and return detached phase facts.""" async def evaluate( - self, request: PostSubmissionEvaluationRequest, material: SubmissionMaterialView, - ) -> PostSubmissionEvaluationResult: + self, request: PostSubmissionExecutionRequest, material: SubmissionMaterialView, + ) -> PostSubmissionExecutionResult: """Return detached closed phase facts before ART revokes material access.""" @@ -126,7 +137,7 @@ class PostSubmissionMaterializationResult: content_sha256: str byte_count: int semantic_manifest_sha256: str - evaluation: PostSubmissionEvaluationResult + evaluation: PostSubmissionExecutionResult input_materialization_evidence_id: UUID diff --git a/backend/app/modules/checkers/api/output_custody.py b/backend/app/modules/checkers/api/output_custody.py index be8a9b36c..c0a8fcc9b 100644 --- a/backend/app/modules/checkers/api/output_custody.py +++ b/backend/app/modules/checkers/api/output_custody.py @@ -7,10 +7,10 @@ from pydantic import Field, StrictInt, model_validator -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationRequest -from app.modules.checkers.api.post_submit_catalogue import ( +from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest +from app.modules.checkers.api.canonical_values import ( + CheckerValue, Identifier, - PostSubmitValue, ResourceId, VersionNumber, ) @@ -20,17 +20,17 @@ class CheckerOutputUnavailable(RuntimeError): """Conceal missing, stale or unauthorized checker output custody.""" -class CheckerOutputSelector(PostSubmitValue): +class CheckerOutputSelector(CheckerValue): """Select exact immutable evaluation and the caller's claimed execution lease.""" - evaluation: PostSubmissionEvaluationRequest + evaluation: PostSubmissionExecutionRequest checker_run_id: ResourceId worker_lease_id: ResourceId worker_lease_generation: VersionNumber slot_key: Identifier -class CheckerOutputSlot(PostSubmitValue): +class CheckerOutputSlot(CheckerValue): """One globally unique run slot issued by CHECKERS, not by a contributor.""" key: Identifier @@ -38,10 +38,10 @@ class CheckerOutputSlot(PostSubmitValue): maximum_bytes: Annotated[StrictInt, Field(ge=1, le=536_870_912)] -class CheckerOutputReservation(PostSubmitValue): +class CheckerOutputReservation(CheckerValue): """Detached owner facts, never a substitute for fresh action authority.""" - evaluation: PostSubmissionEvaluationRequest + evaluation: PostSubmissionExecutionRequest checker_run_id: ResourceId worker_lease_id: ResourceId worker_lease_generation: VersionNumber diff --git a/backend/app/modules/checkers/api/policy_compilation.py b/backend/app/modules/checkers/api/policy_compilation.py deleted file mode 100644 index 04db38486..000000000 --- a/backend/app/modules/checkers/api/policy_compilation.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Typed bundle-and-plan compilation; private implementation stays in CHECKERS.""" - -from dataclasses import dataclass -from typing import Any, Mapping, Protocol - -from app.modules.checkers.api.pre_submit import EffectivePreSubmissionPlanningPort - - -@dataclass(frozen=True) -class CompiledPreSubmitCheckerPolicy: - """Compiled bundle plus index projections persisted on policy rows.""" - - compiler_version: str - compiled_bundle: dict[str, Any] - compiled_bundle_hash: str - checker_names: list[str] - checker_configs: dict[str, Any] - - - -class PreSubmissionPolicyCompilationPort(EffectivePreSubmissionPlanningPort, Protocol): - """The canonical catalogue supplies both compilation steps to approval.""" - - def compile_policy_bundle( - self, *, effective_policy: Mapping[str, object], effective_policy_hash: str, - ) -> CompiledPreSubmitCheckerPolicy: - """Compile the exact policy, raising ValueError for an unsupported contract.""" diff --git a/backend/app/modules/checkers/api/policy_custody.py b/backend/app/modules/checkers/api/policy_custody.py new file mode 100644 index 000000000..3b888e595 --- /dev/null +++ b/backend/app/modules/checkers/api/policy_custody.py @@ -0,0 +1,15 @@ +"""Persisted checker policy custody shared by selected policy owners.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class CompiledPreSubmitCheckerPolicy: + """Compiled bundle plus index projections persisted on policy rows.""" + + compiler_version: str + compiled_bundle: dict[str, Any] + compiled_bundle_hash: str + checker_names: list[str] + checker_configs: dict[str, Any] diff --git a/backend/app/modules/checkers/api/post_submit.py b/backend/app/modules/checkers/api/post_submit.py deleted file mode 100644 index afabaf787..000000000 --- a/backend/app/modules/checkers/api/post_submit.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Detached, bounded post-submit phase facts; no persistence or authorization.""" - -from __future__ import annotations - -from typing import Annotated, Literal, Protocol, Self - -from pydantic import TypeAdapter, Field, StrictInt, StrictStr, model_validator - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api.post_submit_catalogue import ( - ByteCount, - CompiledPostSubmitPolicy, - GuideVersion, - Identifier, - PostSubmitCatalogue, - PostSubmitValue, - ResourceId, - Severity, - Sha256, - VersionNumber, - canonical_post_submit_bytes, -) - -Text = Annotated[StrictStr, Field(max_length=65_536)] -PathText = Annotated[StrictStr, Field(max_length=1000)] -KeyText = Annotated[StrictStr, Field(max_length=200)] -HashText = Annotated[StrictStr, Field(max_length=128)] -CounterValue = Annotated[StrictInt, Field(ge=0, le=1024)] - - -class PostSubmitManifestEntry(PostSubmitValue): - """Untrusted structural text remains representable so checkers can reject it.""" - - artifact: PathText - hash: HashText - notes: Annotated[StrictStr, Field(max_length=4096)] | None = None - size_bytes: ByteCount | None = None - - -class PostSubmitEvidenceEntry(PostSubmitValue): - """A finite evidence projection without arbitrary metadata or lazy relations.""" - - label: KeyText - uri: PathText | None = None - hash: HashText | None = None - type: KeyText - key: KeyText | None = None - policy_key: KeyText | None = None - evidence_key: KeyText | None = None - required_evidence_key: KeyText | None = None - - -class PostSubmitPolicyInputs(PostSubmitValue): - """Detached effective-policy values; the later owning resolver verifies derivation.""" - - required_evidence_keys: tuple[PathText, ...] = Field(default=(), max_length=256) - required_artifact_paths: tuple[PathText, ...] = Field(default=(), max_length=256) - forbidden_artifact_patterns: tuple[PathText, ...] = Field(default=(), max_length=256) - required_attestation_terms: tuple[PathText, ...] = Field(default=(), max_length=256) - - -class ObservedPostSubmitContext(PostSubmitValue): - """Nullable observed source fields permit precise missing-context outcomes.""" - - guide_version: GuideVersion | None = None - source_id: ResourceId | None = None - source_hash: Sha256 | None = None - effective_policy_id: ResourceId | None = None - effective_policy_hash: Sha256 | None = None - pre_policy_id: ResourceId | None = None - pre_policy_hash: Sha256 | None = None - post_policy_id: ResourceId | None = None - post_policy_version: GuideVersion | None = None - post_policy_hash: Sha256 | None = None - review_policy_id: ResourceId | None = None - review_generation: VersionNumber | None = None - review_hash: Sha256 | None = None - revision_policy_id: ResourceId | None = None - revision_generation: VersionNumber | None = None - revision_hash: Sha256 | None = None - - -class ExpectedPostSubmitContext(ObservedPostSubmitContext): - """Complete currently persisted task lineage expected on a submission.""" - - guide_version: GuideVersion - source_id: ResourceId - source_hash: Sha256 - effective_policy_id: ResourceId - effective_policy_hash: Sha256 - pre_policy_id: ResourceId - pre_policy_hash: Sha256 - post_policy_id: ResourceId - post_policy_version: GuideVersion - post_policy_hash: Sha256 - review_policy_id: ResourceId - review_generation: VersionNumber - review_hash: Sha256 - revision_policy_id: ResourceId - revision_generation: VersionNumber - revision_hash: Sha256 - - @model_validator(mode="after") - def validate_versions(self) -> Self: - """Preserve the existing post-policy/guide string-version relationship.""" - if self.post_policy_version != self.guide_version: - raise ValueError("post-submit guide and policy versions differ") - return self - - -class PostSubmissionStructuralInput(PostSubmitValue): - """Immutable detached values consumed by the actual structural handlers.""" - - schema_version: Literal["post_submit_structural_input"] = "post_submit_structural_input" - summary: Text - worker_attestation: Text - package_hash: HashText - criteria: Text - manifest: tuple[PostSubmitManifestEntry, ...] = Field(max_length=1024) - evidence: tuple[PostSubmitEvidenceEntry, ...] = Field(max_length=1024) - policy_inputs: PostSubmitPolicyInputs - observed_context: ObservedPostSubmitContext - - -# Keep the locked catalogue's 1 MiB request ceiling; reserve its finite envelope. -POST_SUBMIT_CONTENT_BYTE_LIMIT = 1_048_576 - 1024 - - -class PostSubmissionEvaluationContent(PostSubmitValue): - """Exact bounded content shared by admission and later identified execution.""" - - project_id: ResourceId - expected_context: ExpectedPostSubmitContext - catalogue: PostSubmitCatalogue - policy: CompiledPostSubmitPolicy - structural_input: PostSubmissionStructuralInput - - @model_validator(mode="after") - def validate_content(self) -> Self: - """Reject impossible content before any record identities are allocated.""" - if len(canonical_post_submit_bytes( - self, include=set(PostSubmissionEvaluationContent.model_fields), - )) > POST_SUBMIT_CONTENT_BYTE_LIMIT: - raise ValueError("post-submit content capacity exceeded") - if self.project_id != self.policy.project_id: - raise ValueError("post-submit request project mismatch") - if self.policy.guide_version != self.expected_context.guide_version: - raise ValueError("post-submit request guide version mismatch") - if self.policy.policy_hash != self.expected_context.post_policy_hash: - raise ValueError("post-submit request policy hash mismatch") - self.policy.validate_catalogue(self.catalogue) - return self - - -class PostSubmissionEvaluationRequest(PostSubmissionEvaluationContent): - """Hash-bound value consistency, never stored ownership or execution authority.""" - - schema_version: Literal["post_submit_evaluation_request"] = ( - "post_submit_evaluation_request" - ) - evaluation_request_id: ResourceId - evaluation_generation: VersionNumber - task_id: ResourceId - assignment_id: ResourceId - submission_id: ResourceId - submission_version: VersionNumber - content_id: ResourceId - binding_id: ResourceId - content_sha256: Sha256 - byte_count: ByteCount - request_sha256: Sha256 - - @model_validator(mode="after") - def validate_request(self) -> Self: - """Retain the full envelope ceiling and exact request digest.""" - if len(canonical_post_submit_bytes(self)) > 1_048_576: - raise ValueError("post-submit request capacity exceeded") - expected = canonical_json_hash(self.model_dump(mode="json", exclude={"request_sha256"})) - if self.request_sha256 != expected: - raise ValueError("post-submit request digest mismatch") - return self - - -class PostSubmitCounter(PostSubmitValue): - """Closed counters cannot carry arbitrary checker messages or references.""" - - key: Literal["artifact_count", "missing_count", "invalid_count", "matched_count"] - value: CounterValue - - -class PostSubmitMemberResult(PostSubmitValue): - """One sanitized structural outcome, validated against its pinned definition.""" - - schema_version: Literal["post_submit_structural_result"] = "post_submit_structural_result" - checker_id: Identifier - definition_version: Literal["v0.1"] = "v0.1" - implementation_version: Identifier - status: Literal["passed", "warning", "failed"] - code: Literal[ - "passed", - "packet_fields_missing", - "policy_context_invalid", - "evidence_missing", - "evidence_structure_invalid", - "required_files_missing", - "forbidden_path_present", - "attestation_missing", - "placeholder_signal", - "acceptance_criteria_missing", - ] - failure_category: Literal["none", "submission_structure", "task_configuration"] - severity: Severity - counters: tuple[PostSubmitCounter, ...] = Field(default=(), max_length=4) - - @model_validator(mode="after") - def validate_shape(self) -> Self: - """Check closed passing/failing combinations and finite counters.""" - passed_fields = ( - self.code == "passed", - self.failure_category == "none", - self.severity == "info", - ) - if self.status == "passed" and not all(passed_fields): - raise ValueError("post-submit passing result shape is invalid") - if self.status != "passed" and any(passed_fields): - raise ValueError("post-submit nonpassing result shape is invalid") - if len({item.key for item in self.counters}) != len(self.counters): - raise ValueError("post-submit result counters repeat") - return self - - def validate_catalogue(self, catalogue: PostSubmitCatalogue) -> None: - """Require exactly the registered implementation's claim-specific outcome.""" - self = PostSubmitMemberResult.model_validate(self) - definition = catalogue.definition(self.checker_id, self.definition_version) - if self.implementation_version != definition.implementation_version: - raise ValueError("post-submit result implementation mismatch") - if self.status != "passed" and ( - self.code, - self.failure_category, - self.status, - self.severity, - ) != ( - definition.failure_code, - definition.failure_category, - definition.failure_status, - definition.failure_severity, - ): - raise ValueError("post-submit result does not match registered claim") - - -class PostSubmissionEvaluationResult(PostSubmitValue): - """A typed phase result is not evidence that a durable result is current.""" - - schema_version: Literal["post_submit_evaluation_result"] = "post_submit_evaluation_result" - request_id: ResourceId - request_digest: Sha256 - attempt_id: ResourceId - evaluation_generation: VersionNumber - result_id: ResourceId - result_digest: Sha256 - outcome: Literal["completed", "infrastructure_failed"] - member_results: tuple[PostSubmitMemberResult, ...] = Field(max_length=9) - infrastructure_failure_code: ( - Literal[ - "capacity_exceeded", - "deadline_exceeded", - "material_unavailable", - "implementation_unavailable", - "invalid_output", - ] - | None - ) = None - - @model_validator(mode="after") - def validate_result(self) -> Self: - """Separate infrastructure failure and recompute exact result identity.""" - if self.outcome == "completed": - if not self.member_results or self.infrastructure_failure_code is not None: - raise ValueError("post-submit completed result shape is invalid") - elif self.member_results or self.infrastructure_failure_code is None: - raise ValueError("post-submit infrastructure result shape is invalid") - if self.result_digest != canonical_json_hash( - self.model_dump(mode="json", exclude={"result_digest"}) - ): - raise ValueError("post-submit result digest mismatch") - return self - - def validate_request(self, request: PostSubmissionEvaluationRequest) -> None: - """Bind the complete member list to the exact request without claiming custody.""" - self = PostSubmissionEvaluationResult.model_validate(self) - request = PostSubmissionEvaluationRequest.model_validate(request) - if (self.request_id, self.request_digest, self.evaluation_generation) != ( - request.evaluation_request_id, - request.request_sha256, - request.evaluation_generation, - ): - raise ValueError("post-submit result request mismatch") - if self.outcome == "completed": - expected = tuple( - (entry.checker_id, entry.definition_version, entry.implementation_version) - for entry in request.policy.entries - ) - actual = tuple( - (item.checker_id, item.definition_version, item.implementation_version) - for item in self.member_results - ) - if actual != expected: - raise ValueError("post-submit result members mismatch") - for member in self.member_results: - member.validate_catalogue(request.catalogue) - - -class PostSubmitCurrentResultReference(PostSubmitValue): - """A later owner-issued reference, with no self-authorizing is_current flag.""" - - schema_version: Literal["post_submit_current_result_reference"] = ( - "post_submit_current_result_reference" - ) - request_id: ResourceId - request_digest: Sha256 - attempt_id: ResourceId - evaluation_generation: VersionNumber - result_id: ResourceId - result_digest: Sha256 - - def validate_result(self, result: PostSubmissionEvaluationResult) -> None: - """Match exact completed result identity; the repository must prove currentness.""" - self = PostSubmitCurrentResultReference.model_validate(self) - result = PostSubmissionEvaluationResult.model_validate(result) - names = ( - "request_id", - "request_digest", - "attempt_id", - "evaluation_generation", - "result_id", - "result_digest", - ) - if result.outcome != "completed" or any( - getattr(self, key) != getattr(result, key) for key in names - ): - raise ValueError("post-submit current reference result mismatch") - - -class PostSubmissionExecutionPort(Protocol): - """One complete locked phase command, never caller-selected individual checks.""" - - async def evaluate_post_submission( - self, request: PostSubmissionEvaluationRequest - ) -> PostSubmissionEvaluationResult: - """Evaluate one request after later owner resolution and authority checks.""" - ... - - -_REQUEST_FIELDS = { - name: TypeAdapter(field.rebuild_annotation()) - for name, field in PostSubmissionEvaluationRequest.model_fields.items() -} - -def make_post_submit_request(**fields: object) -> PostSubmissionEvaluationRequest: - """Compute identity and then validate every field; this grants no authority.""" - if "request_sha256" in fields: - raise ValueError("request identity is derived, not caller selected") - fields = { - name: _REQUEST_FIELDS[name].validate_python(value) if name in _REQUEST_FIELDS else value - for name, value in fields.items() - } - candidate = PostSubmissionEvaluationRequest.model_construct( - **fields, request_sha256="sha256:" + "0" * 64 - ) - body = candidate.model_dump(mode="json", exclude={"request_sha256"}) - fields["request_sha256"] = canonical_json_hash(body) - return PostSubmissionEvaluationRequest.model_validate(fields) diff --git a/backend/app/modules/checkers/api/post_submit_catalogue.py b/backend/app/modules/checkers/api/post_submit_catalogue.py deleted file mode 100644 index 82dec5e32..000000000 --- a/backend/app/modules/checkers/api/post_submit_catalogue.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Immutable post-submit catalogue and compiled-policy value contracts.""" - -from __future__ import annotations - -import json -from collections.abc import Mapping -from typing import Annotated, Literal, Self -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, 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_.-]*$") -] -VersionNumber = Annotated[StrictInt, Field(ge=1, le=2_147_483_647)] -GuideVersion = Annotated[StrictStr, Field(min_length=1, max_length=50, pattern=r"\S")] -ResourceId = Annotated[UUID, Field(strict=True)] -ByteCount = Annotated[StrictInt, Field(ge=0, le=9_223_372_036_854_775_807)] -Severity = Literal["info", "low", "medium", "high", "critical"] -POST_SUBMIT_IMPLEMENTATION_ID = "workstream-structural" -POST_SUBMIT_COMPILER_ID = "workstream-post-submit-compiler" -POST_SUBMIT_CATALOGUE_ID = "workstream.post_submission_checkers" -POST_SUBMIT_CATALOGUE_SCHEMA = "post_submission_checker_capability_projection" -POST_SUBMIT_POLICY_SCHEMA = "post_submit_checker_policy" - - -class PostSubmitValue(BaseModel): - """Reject unknown fields and revalidate even previously constructed instances.""" - - model_config = ConfigDict(extra="forbid", frozen=True, revalidate_instances="always") - - -class EmptyPostSubmitConfiguration(PostSubmitValue): - """The current registered structural implementations accept no parameters.""" - - -class PostSubmitResourceLimits(PostSubmitValue): - """Code-owned input ceilings; deadline enforcement belongs to the executor.""" - - maximum_input_bytes: Literal[1_048_576] = 1_048_576 - maximum_result_bytes: Literal[4096] = 4096 - maximum_results: Literal[1] = 1 - maximum_generated_output_bytes: Literal[0] = 0 - deadline_ms: Literal[5000] = 5000 - phase_deadline_ms: Literal[45000] = 45000 - maximum_manifest_items: Literal[1024] = 1024 - maximum_evidence_items: Literal[1024] = 1024 - maximum_policy_items: Literal[256] = 256 - maximum_text_characters: Literal[65536] = 65536 - - @model_validator(mode="before") - @classmethod - def reject_noninteger_limits(cls, value: object) -> object: - """Exact integer literals must reject boolean, float and string coercion.""" - if isinstance(value, Mapping) and any(type(item) is not int for item in value.values()): - raise ValueError("post-submit limits require integers") - return value - - -class PostSubmitDefinition(PostSubmitValue): - """Registered implementation metadata, never a callable or authority grant.""" - - capability_id: Identifier - capability_version: Literal["v0.1"] = "v0.1" - implementation_version: Identifier - stage: Literal["post_submit"] = "post_submit" - platform_default: StrictBool - selectable: StrictBool - state: Literal["enabled", "disabled"] = "enabled" - disabled_behavior: Literal["unavailable"] = "unavailable" - execution_method: Literal["deterministic"] = "deterministic" - configuration_schema: Literal["post_submit_empty_configuration"] = ( - "post_submit_empty_configuration" - ) - input_schema: Literal["post_submit_structural_input"] = "post_submit_structural_input" - result_schema: Literal["post_submit_structural_result"] = "post_submit_structural_result" - order: Annotated[StrictInt, Field(ge=1, le=9)] - dependencies: tuple[Identifier, ...] = Field(default=(), max_length=8) - supported_claim: Literal[ - "packet_presence", - "policy_context_consistency", - "evidence_presence", - "evidence_structure", - "required_path_presence", - "forbidden_path_detection", - "attestation_presence", - "placeholder_signal_detection", - "criteria_presence", - ] - failure_code: Literal[ - "packet_fields_missing", - "policy_context_invalid", - "evidence_missing", - "evidence_structure_invalid", - "required_files_missing", - "forbidden_path_present", - "attestation_missing", - "placeholder_signal", - "acceptance_criteria_missing", - ] - failure_category: Literal["submission_structure", "task_configuration"] - failure_status: Literal["warning", "failed"] = "failed" - failure_severity: Literal["medium", "high"] = "high" - resources: PostSubmitResourceLimits = PostSubmitResourceLimits() - provider_recovery: Literal["none"] = "none" - required_output_roles: tuple[Identifier, ...] = Field(default=(), max_length=0) - - @model_validator(mode="after") - def validate_classification(self) -> Self: - """Require exactly one policy classification and consistent warning severity.""" - if len(self.dependencies) != len(set(self.dependencies)): - raise ValueError("post-submit definition has duplicate dependencies") - if self.platform_default == self.selectable: - raise ValueError("post-submit definition classification is invalid") - if (self.failure_status == "warning") != (self.failure_severity == "medium"): - raise ValueError("post-submit failure severity is invalid") - return self - - def validate_configuration(self, configuration: object) -> EmptyPostSubmitConfiguration: - """Use the exact registered closed schema, including for empty parameters.""" - self = PostSubmitDefinition.model_validate(self) - if self.state != "enabled": - raise ValueError("post-submit definition is unavailable") - return EmptyPostSubmitConfiguration.model_validate(configuration) - - -class PostSubmitCatalogue(PostSubmitValue): - """A canonical frozen snapshot; its hash proves identity, not installation.""" - - catalogue_id: Literal["workstream.post_submission_checkers"] = POST_SUBMIT_CATALOGUE_ID - source_version: Literal["v0.1"] = "v0.1" - schema_version: Literal["post_submission_checker_capability_projection"] = ( - POST_SUBMIT_CATALOGUE_SCHEMA - ) - definitions: tuple[PostSubmitDefinition, ...] = Field(min_length=1, max_length=9) - manifest_sha256: Sha256 - - @model_validator(mode="after") - def validate_snapshot(self) -> Self: - """Validate order/dependencies and all hash-bound definition semantics.""" - seen: set[str] = set() - orders: set[int] = set() - previous = 0 - for definition in self.definitions: - if definition.capability_id in seen or definition.order in orders: - raise ValueError("post-submit catalogue has duplicate definitions") - if definition.order <= previous or not set(definition.dependencies).issubset(seen): - raise ValueError("post-submit catalogue order or dependency is invalid") - seen.add(definition.capability_id) - orders.add(definition.order) - previous = definition.order - if self.manifest_sha256 != canonical_json_hash( - self.model_dump(mode="json", exclude={"manifest_sha256"}) - ): - raise ValueError("post-submit catalogue hash mismatch") - return self - - def definition(self, capability_id: str, version: str) -> PostSubmitDefinition: - """Select exactly the pinned definition, with no latest-version substitution.""" - self = PostSubmitCatalogue.model_validate(self) - for definition in self.definitions: - if (definition.capability_id, definition.capability_version) == ( - capability_id, - version, - ): - return definition - raise ValueError("post-submit capability or version is unavailable") - - -class PostSubmitPolicyEntry(PostSubmitValue): - """One ordered compiled entry pinned to an exact registered implementation.""" - - checker_id: Identifier - definition_version: Literal["v0.1"] - implementation_version: Identifier - classification: Literal["platform_default", "project_required", "project_warning"] - configuration: EmptyPostSubmitConfiguration - - -class CompiledPostSubmitPolicy(PostSubmitValue): - """Canonical policy body; its existing policy_hash remains the only plan hash.""" - - schema_version: Literal["post_submit_checker_policy"] = POST_SUBMIT_POLICY_SCHEMA - compiler_version: Literal["workstream-post-submit-compiler"] = POST_SUBMIT_COMPILER_ID - project_id: ResourceId - guide_version: GuideVersion - catalogue_id: Literal["workstream.post_submission_checkers"] = POST_SUBMIT_CATALOGUE_ID - catalogue_source_version: Literal["v0.1"] = "v0.1" - catalogue_schema_version: Literal["post_submission_checker_capability_projection"] = ( - POST_SUBMIT_CATALOGUE_SCHEMA - ) - catalogue_manifest_sha256: Sha256 - entries: tuple[PostSubmitPolicyEntry, ...] = Field(min_length=1, max_length=9) - blocking_severities: tuple[Severity, ...] = Field(min_length=2, max_length=5) - - @model_validator(mode="after") - def validate_unique_entries(self) -> Self: - """Never repeat a member or weaken the mandatory blocking floor.""" - if len({entry.checker_id for entry in self.entries}) != len(self.entries): - raise ValueError("post-submit policy has duplicate entries") - order = ("critical", "high", "medium", "low", "info") - expected = tuple(item for item in order if item in self.blocking_severities) - if expected != self.blocking_severities or not {"critical", "high"}.issubset(expected): - raise ValueError("post-submit policy blocking severities are invalid") - return self - - @property - def policy_hash(self) -> str: - """Hash the canonical body without adding a second persisted hash field.""" - return canonical_json_hash(self.model_dump(mode="json")) - - @property - def policy_body(self) -> dict: - """Return the one serialized policy representation.""" - return self.model_dump(mode="json") - - @property - def default_checkers(self) -> list[str]: - """Derive mandatory entries from the canonical policy.""" - return [ - entry.checker_id for entry in self.entries if entry.classification == "platform_default" - ] - - @property - def required_checkers(self) -> list[str]: - """Derive project-required entries from the canonical policy.""" - return [ - entry.checker_id for entry in self.entries if entry.classification == "project_required" - ] - - @property - def warning_checkers(self) -> list[str]: - """Derive advisory project entries from the canonical policy.""" - return [ - entry.checker_id for entry in self.entries if entry.classification == "project_warning" - ] - - @property - def execution_checkers(self) -> list[str]: - """Derive execution order without another stored plan.""" - return [entry.checker_id for entry in self.entries] - - def validate_sidecars( - self, - *, - required_checkers: list[str], - warning_checkers: list[str], - blocking_severities: list[str], - ) -> None: - """Reject persisted policy summaries that disagree with the canonical body.""" - if ( - required_checkers != self.required_checkers - or warning_checkers != self.warning_checkers - or blocking_severities != list(self.blocking_severities) - ): - raise ValueError("post-submit policy summaries disagree with canonical body") - - def validate_catalogue(self, catalogue: PostSubmitCatalogue) -> None: - """Validate exact pinned members, defaults, configuration and ordering.""" - self = CompiledPostSubmitPolicy.model_validate(self) - catalogue = PostSubmitCatalogue.model_validate(catalogue) - if self.catalogue_manifest_sha256 != catalogue.manifest_sha256: - raise ValueError("post-submit policy catalogue hash mismatch") - defaults = {item.capability_id for item in catalogue.definitions if item.platform_default} - actual_defaults: set[str] = set() - previous = 0 - seen: set[str] = set() - for entry in self.entries: - definition = catalogue.definition(entry.checker_id, entry.definition_version) - if entry.implementation_version != definition.implementation_version: - raise ValueError("post-submit implementation version mismatch") - if (entry.classification == "platform_default") != definition.platform_default: - raise ValueError("post-submit policy default classification mismatch") - definition.validate_configuration(entry.configuration) - if definition.order <= previous or not set(definition.dependencies).issubset(seen): - raise ValueError("post-submit policy order or dependency mismatch") - if definition.platform_default: - actual_defaults.add(entry.checker_id) - previous = definition.order - seen.add(entry.checker_id) - if defaults != actual_defaults: - raise ValueError("post-submit policy omits mandatory defaults") - - -def canonical_post_submit_bytes( - value: PostSubmitValue, *, exclude: set[str] | None = None, include: set[str] | None = None -) -> bytes: - """Use the canonical hash encoding for finite serialized-size checks.""" - return json.dumps( - value.model_dump(mode="json", exclude=exclude, include=include), - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") - - -_STRUCTURAL_DEFINITIONS = ( - ("check_submission_packet", "packet_presence", "packet_fields_missing", "submission_structure"), - ( - "check_policy_context_present", - "policy_context_consistency", - "policy_context_invalid", - "task_configuration", - ), - ("check_evidence_present", "evidence_presence", "evidence_missing", "submission_structure"), - ( - "check_evidence_integrity", - "evidence_structure", - "evidence_structure_invalid", - "submission_structure", - ), - ( - "check_required_files", - "required_path_presence", - "required_files_missing", - "submission_structure", - ), - ( - "check_forbidden_files", - "forbidden_path_detection", - "forbidden_path_present", - "submission_structure", - ), - ( - "check_confidentiality_attestation", - "attestation_presence", - "attestation_missing", - "submission_structure", - ), - ( - "check_low_quality_generated_artifacts", - "placeholder_signal_detection", - "placeholder_signal", - "submission_structure", - ), - ( - "check_acceptance_criteria_present", - "criteria_presence", - "acceptance_criteria_missing", - "task_configuration", - ), -) - - -def structural_definition(name: str) -> PostSubmitDefinition: - """Mint the code-owned metadata attached to an explicit registration entry.""" - for order, (checker_id, claim, failure, category) in enumerate(_STRUCTURAL_DEFINITIONS, 1): - if checker_id == name: - warning = name == "check_low_quality_generated_artifacts" - return PostSubmitDefinition( - capability_id=name, - implementation_version=POST_SUBMIT_IMPLEMENTATION_ID, - platform_default=order <= 8, - selectable=order == 9, - order=order, - supported_claim=claim, - failure_code=failure, - failure_category=category, - failure_status="warning" if warning else "failed", - failure_severity="medium" if warning else "high", - ) - raise ValueError("unknown structural post-submit definition") - - -def current_post_submit_catalogue() -> PostSubmitCatalogue: - """Return code-owned immutable metadata, without execution authority.""" - definitions = tuple(structural_definition(row[0]) for row in _STRUCTURAL_DEFINITIONS) - body = { - "catalogue_id": POST_SUBMIT_CATALOGUE_ID, - "source_version": "v0.1", - "schema_version": POST_SUBMIT_CATALOGUE_SCHEMA, - "definitions": [item.model_dump(mode="json") for item in definitions], - } - return PostSubmitCatalogue(**body, manifest_sha256=canonical_json_hash(body)) diff --git a/backend/app/modules/checkers/api/pre_submit.py b/backend/app/modules/checkers/api/pre_submit.py deleted file mode 100644 index 07a9aa39a..000000000 --- a/backend/app/modules/checkers/api/pre_submit.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Dependency-safe CHECKER contracts for pre-submission planning and results.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Mapping, Protocol -from uuid import UUID - - -EFFECTIVE_PRE_SUBMISSION_PLAN_SCHEMA_VERSION = "effective_pre_submission_plan.v1" -EFFECTIVE_PRE_SUBMISSION_PLAN_HASH_DOMAIN = "workstream.effective_pre_submission_plan.v1" -ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES = frozenset({"local", "s3"}) -_RESULT_MESSAGE_CODES = frozenset( - { - "advisory_disabled", - "attestation_missing", - "dependency_not_run", - "file_size_limit_exceeded", - "forbidden_artifact_present", - "package_size_limit_exceeded", - "archive_entry_limit_exceeded", - "archive_size_limit_exceeded", - "packaging_requirement_failed", - "passed", - "policy_attestation_missing", - "quality_signal_warning", - "required_evidence_missing", - "required_file_missing", - "sensitive_path_forbidden", - "storage_scheme_not_allowed", - "submission_packet_invalid", - } -) -PRE_SUBMISSION_RESULT_METADATA_KEYS = frozenset( - {"entry_count", "finding_count", "matched_category_count"} -) -_EXECUTION_STATUSES = frozenset( - {"passed", "warning", "advisory_disabled", "dependency_not_run", "failed"} -) - - -class EffectivePreSubmissionPlanError(ValueError): - """Reject a stale, ambiguous, or unavailable effective plan.""" - - -class PreSubmissionInfrastructureUnavailableError(EffectivePreSubmissionPlanError): - """Fail preparation closed when mandatory deployment capability is disabled.""" - - -@dataclass(frozen=True, slots=True) -class EffectivePreSubmissionPlanLineage: - """Exact server-locked lineage used to compile one checker plan.""" - - project_id: UUID - guide_id: UUID - guide_version: str - source_snapshot_id: UUID - source_snapshot_hash: str - effective_policy_id: UUID - effective_policy_hash: str - pre_submit_policy_id: UUID - pre_submit_policy_bundle_hash: str - - def __post_init__(self) -> None: - if any( - type(value) is not UUID - for value in ( - self.project_id, - self.guide_id, - self.source_snapshot_id, - self.effective_policy_id, - self.pre_submit_policy_id, - ) - ): - raise EffectivePreSubmissionPlanError("effective plan lineage id is invalid") - if type(self.guide_version) is not str or not 1 <= len(self.guide_version) <= 50: - raise EffectivePreSubmissionPlanError("effective plan guide version is invalid") - for value in ( - self.source_snapshot_hash, - self.effective_policy_hash, - self.pre_submit_policy_bundle_hash, - ): - _validate_sha256(value) - - def as_dict(self) -> dict[str, Any]: - return { - "project_id": str(self.project_id), - "guide_id": str(self.guide_id), - "guide_version": self.guide_version, - "source_snapshot_id": str(self.source_snapshot_id), - "source_snapshot_hash": self.source_snapshot_hash, - "effective_policy_id": str(self.effective_policy_id), - "effective_policy_hash": self.effective_policy_hash, - "pre_submit_policy_id": str(self.pre_submit_policy_id), - "pre_submit_policy_bundle_hash": self.pre_submit_policy_bundle_hash, - } - - -@dataclass(frozen=True, slots=True) -class FrozenJsonObject: - """Hashable deeply immutable projection of one canonical JSON object.""" - - items: tuple[tuple[str, Any], ...] - - def as_dict(self) -> dict[str, Any]: - return {key: thaw_json(value) for key, value in self.items} - - -@dataclass(frozen=True, slots=True) -class EffectivePreSubmissionPlanEntry: - """One ordered checker definition or locked-policy rule instance.""" - - definition_id: str - definition_version: str - public_name: str - phase: str - order: int - dependencies: tuple[str, ...] - classification: str - checker_definition_state: str - disabled_behavior: str - dispatch_kind: str - dispatch_capability: str - typed_inputs: tuple[str, ...] - result_schema: str - failure_code: str - resource_budget: tuple[tuple[str, int], ...] - policy_trace_source: str - rule_instance_id: str | None - configuration: FrozenJsonObject - configuration_sha256: str - - def as_dict(self) -> dict[str, Any]: - return { - "definition_id": self.definition_id, - "definition_version": self.definition_version, - "public_name": self.public_name, - "phase": self.phase, - "order": self.order, - "dependencies": list(self.dependencies), - "classification": self.classification, - "checker_definition_state": self.checker_definition_state, - "disabled_behavior": self.disabled_behavior, - "dispatch_kind": self.dispatch_kind, - "dispatch_capability": self.dispatch_capability, - "typed_inputs": list(self.typed_inputs), - "result_schema": self.result_schema, - "failure_code": self.failure_code, - "resource_budget": dict(self.resource_budget), - "policy_trace_source": self.policy_trace_source, - "rule_instance_id": self.rule_instance_id, - "configuration": self.configuration.as_dict(), - "configuration_sha256": self.configuration_sha256, - } - - -@dataclass(frozen=True, slots=True) -class EffectivePreSubmissionExecutionPlan: - """Canonical side-effect-free plan returned by the CHECKER capability.""" - - lineage: EffectivePreSubmissionPlanLineage - catalogue_id: str - catalogue_version: str - catalogue_schema_version: str - catalogue_manifest_sha256: str - entries: tuple[EffectivePreSubmissionPlanEntry, ...] - plan_sha256: str - - def as_dict(self) -> dict[str, Any]: - return effective_plan_body( - self.lineage, - self.catalogue_id, - self.catalogue_version, - self.catalogue_schema_version, - self.catalogue_manifest_sha256, - self.entries, - ) - - -class EffectivePreSubmissionPlanningPort(Protocol): - """CHECKER-owned deterministic planning capability.""" - - def compile_effective_plan( - self, - *, - lineage: EffectivePreSubmissionPlanLineage, - effective_policy: Mapping[str, object], - compiled_bundle: Mapping[str, object], - ) -> EffectivePreSubmissionExecutionPlan: - """Compile one plan from exact immutable locked-policy facts.""" - - -@dataclass(frozen=True, slots=True) -class SubmissionPacketView: - """Bounded contributor-authored text accompanying server-owned ZIP facts.""" - - summary: str - contributor_attestation: str - - def __post_init__(self) -> None: - for value in (self.summary, self.contributor_attestation): - if type(value) is not str or len(value.encode("utf-8")) > 64 * 1024: - raise ValueError("submission packet text is invalid") - - @property - def sha256(self) -> str: - """Commit to the same exact text at preparation and Submission creation.""" - from app.core.hashing import canonical_json_hash - - return canonical_json_hash({ - "summary": self.summary, - "contributor_attestation": self.contributor_attestation, - }) - - -@dataclass(frozen=True, slots=True) -class PreSubmissionExecutionEntryFacts: - """One bounded checker outcome without ART custody or persistence facts.""" - - dispatch_authority: str - definition_id: str - definition_version: str - public_name: str - policy_source: str - effective_plan_sha256: str - rule_instance_id: str | None - locked_policy_sha256: str - phase: str - order: int - classification: str - severity: str - checker_execution_status: str - failure_code: str | None - message_code: str - metadata: tuple[tuple[str, int], ...] - - -@dataclass(frozen=True, slots=True) -class PreSubmissionExecutionFacts: - """Bounded immutable CHECKER result safe for cross-module use.""" - - plan_sha256: str - eligible: bool - entries: tuple[PreSubmissionExecutionEntryFacts, ...] - - -def validate_pre_submission_execution_facts( - plan: EffectivePreSubmissionExecutionPlan, - execution: PreSubmissionExecutionFacts, -) -> None: - """Validate one bounded CHECKER result against its exact immutable plan.""" - if ( - type(execution.eligible) is not bool - or execution.plan_sha256 != plan.plan_sha256 - or len(execution.entries) != len(plan.entries) - ): - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_result_context_invalid" - ) - disqualified = False - for plan_entry, result in zip(plan.entries, execution.entries, strict=True): - expected_severity = ( - "warning" if plan_entry.classification == "advisory" else "blocking" - ) - status = result.checker_execution_status - if ( - result.dispatch_authority != "workstream.pre_submission_checker_catalogue" - or result.definition_id != plan_entry.definition_id - or result.definition_version != plan_entry.definition_version - or result.public_name != plan_entry.public_name - or result.policy_source != plan_entry.policy_trace_source - or result.effective_plan_sha256 != plan.plan_sha256 - or result.rule_instance_id != plan_entry.rule_instance_id - or result.locked_policy_sha256 != plan.lineage.effective_policy_hash - or result.phase != plan_entry.phase - or result.order != plan_entry.order - or result.classification != plan_entry.classification - or result.severity != expected_severity - or status not in _EXECUTION_STATUSES - or result.message_code not in _RESULT_MESSAGE_CODES - or result.failure_code - != (plan_entry.failure_code if status == "failed" else None) - or len(result.metadata) != len({key for key, _ in result.metadata}) - or any( - key not in PRE_SUBMISSION_RESULT_METADATA_KEYS - or type(value) is not int - or value < 0 - for key, value in result.metadata - ) - ): - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_result_context_invalid" - ) - disqualified = disqualified or status in {"failed", "dependency_not_run"} - if execution.eligible == disqualified: - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_result_context_invalid" - ) - - -def effective_plan_body( - lineage: EffectivePreSubmissionPlanLineage, - catalogue_id: str, - catalogue_version: str, - catalogue_schema_version: str, - catalogue_manifest_sha256: str, - entries: tuple[EffectivePreSubmissionPlanEntry, ...], -) -> dict[str, Any]: - """Return the canonical hash body for one public plan.""" - return { - "domain": EFFECTIVE_PRE_SUBMISSION_PLAN_HASH_DOMAIN, - "schema_version": EFFECTIVE_PRE_SUBMISSION_PLAN_SCHEMA_VERSION, - "lineage": lineage.as_dict(), - "catalogue": { - "id": catalogue_id, - "version": catalogue_version, - "schema_version": catalogue_schema_version, - "manifest_sha256": catalogue_manifest_sha256, - }, - "entries": [entry.as_dict() for entry in entries], - } - - -def freeze_json(value: Any) -> Any: - """Convert canonical JSON containers into immutable public values.""" - if isinstance(value, dict): - return FrozenJsonObject( - tuple((key, freeze_json(item)) for key, item in sorted(value.items())) - ) - if isinstance(value, list): - return tuple(freeze_json(item) for item in value) - return value - - -def thaw_json(value: Any) -> Any: - """Return a detached JSON-compatible projection.""" - if isinstance(value, FrozenJsonObject): - return value.as_dict() - if isinstance(value, tuple): - return [thaw_json(item) for item in value] - return value - - -def _validate_sha256(value: str) -> None: - if ( - type(value) is not str - or len(value) != 71 - or not value.startswith("sha256:") - or any(character not in "0123456789abcdef" for character in value[7:]) - ): - raise EffectivePreSubmissionPlanError("effective plan lineage hash is invalid") diff --git a/backend/app/modules/checkers/api/pre_submit_catalogue.py b/backend/app/modules/checkers/api/pre_submit_catalogue.py deleted file mode 100644 index 00a7a4324..000000000 --- a/backend/app/modules/checkers/api/pre_submit_catalogue.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Public immutable pre-submission capability projection owned by CHECKERS.""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, StrictInt - - -class ResourceBudget(BaseModel): - """Frozen canonical ART resource budget.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - maximum_results: StrictInt = Field(ge=1) - - -class PreSubmissionCapabilityDefinition(BaseModel): - """Exact read-only projection of one ART catalogue definition.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - stable_id: str - version: str - public_name: str - owner: str - phase: str - order: int = Field(ge=0) - dependencies: tuple[str, ...] - classification: str - typed_inputs: tuple[str, ...] - result_schema: str - failure_code: str - resource_budget: ResourceBudget - state: Literal["enabled", "disabled"] - disabled_behavior: str - policy_trace_source: str - dispatch_kind: Literal["platform_capability", "policy_primitive"] - dispatch_capability: str - primitive: str | None = None - policy_fields: tuple[str, ...] = () - selectable: bool - - -class PreSubmissionCapabilityProjection(BaseModel): - """Complete immutable ART catalogue plus model-facing selectability.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - catalogue_id: Literal["workstream.pre_submission_checkers"] - version: Literal["v0.1"] - schema_version: Literal["pre_submission_checker_catalogue.v1"] - manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") - available: bool - definitions: tuple[PreSubmissionCapabilityDefinition, ...] diff --git a/backend/app/modules/checkers/api/pre_submit_material.py b/backend/app/modules/checkers/api/pre_submit_material.py new file mode 100644 index 000000000..fc189cdb3 --- /dev/null +++ b/backend/app/modules/checkers/api/pre_submit_material.py @@ -0,0 +1,36 @@ +"""Neutral contributor packet facts used by the selected PRE flow.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.core.hashing import canonical_json_hash + + +ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES = frozenset({"local", "s3"}) + + +@dataclass(frozen=True, slots=True) +class SubmissionPacketView: + """Bounded optional text accompanying server-owned verified ZIP facts.""" + + summary: str + contributor_attestation: str + + def __post_init__(self) -> None: + for value in (self.summary, self.contributor_attestation): + if type(value) is not str or len(value.encode("utf-8")) > 64 * 1024: + raise ValueError("submission packet text is invalid") + + @property + def sha256(self) -> str: + """Commit to the same exact text at preparation and Submission creation.""" + return canonical_json_hash( + { + "summary": self.summary, + "contributor_attestation": self.contributor_attestation, + } + ) + + +__all__ = ("ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES", "SubmissionPacketView") diff --git a/backend/app/modules/checkers/api/selected_post_submit.py b/backend/app/modules/checkers/api/selected_post_submit.py new file mode 100644 index 000000000..2f8880613 --- /dev/null +++ b/backend/app/modules/checkers/api/selected_post_submit.py @@ -0,0 +1,356 @@ +"""Closed evidence-only facts from selected external POST images.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Literal, Self +from uuid import UUID + +from pydantic import Field, model_validator + +from app.core.hashing import canonical_json_hash +from app.modules.checkers.api.external import ExternalCheckerExecutionResult +from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy +from app.modules.checkers.api.canonical_values import ( + ByteCount, + CheckerValue, + ResourceId, + Sha256, + VersionNumber, +) + + +class SelectedPostSubmitLockedContext(CheckerValue): + """Exact TASK and selected-policy lineage carried by a new Submission.""" + + guide_version: str = Field(min_length=1, max_length=50) + source_id: ResourceId + source_hash: Sha256 + effective_policy_id: ResourceId + effective_policy_hash: Sha256 + pre_policy_id: ResourceId + pre_policy_hash: Sha256 + post_policy_id: ResourceId + post_policy_version: str = Field(min_length=1, max_length=50) + post_policy_hash: Sha256 + review_policy_id: ResourceId + review_generation: VersionNumber + review_hash: Sha256 + revision_policy_id: ResourceId + revision_generation: VersionNumber + revision_hash: Sha256 + + +class SelectedPostSubmissionContent(CheckerValue): + """Approved POST image policy plus the exact locked TASK context.""" + + project_id: ResourceId + expected_context: SelectedPostSubmitLockedContext + policy: LockedExternalPostSubmitPolicy + + @model_validator(mode="after") + def validate_content(self) -> Self: + """Require the exact approved policy digest stamped on the Submission.""" + if ( + self.expected_context.post_policy_hash != self.policy.policy_sha256 + ): + raise ValueError("selected POST locked policy mismatch") + return self + + +class SelectedPostSubmissionEvaluationRequest(SelectedPostSubmissionContent): + """Hash-bound selected POST request reserved with one immutable Submission.""" + + schema_version: Literal["workstream.selected_post_submit_request.v1"] = ( + "workstream.selected_post_submit_request.v1" + ) + evaluation_request_id: ResourceId + evaluation_generation: VersionNumber + task_id: ResourceId + assignment_id: ResourceId + submission_id: ResourceId + submission_version: VersionNumber + content_id: ResourceId + binding_id: ResourceId + content_sha256: Sha256 + byte_count: ByteCount + request_sha256: Sha256 + + @model_validator(mode="after") + def validate_request(self) -> Self: + """Bind the entire selected policy and native Submission identity.""" + expected = canonical_json_hash( + self.model_dump(mode="json", exclude={"request_sha256"}) + ) + if self.request_sha256 != expected: + raise ValueError("selected POST request digest mismatch") + return self + + +def make_selected_post_submit_request( + **fields: object, +) -> SelectedPostSubmissionEvaluationRequest: + """Derive the selected request identity before strict validation.""" + if "request_sha256" in fields: + raise ValueError("selected POST request identity is derived") + candidate = SelectedPostSubmissionEvaluationRequest.model_construct( + **fields, + request_sha256="sha256:" + "0" * 64, + ) + fields["request_sha256"] = canonical_json_hash( + candidate.model_dump(mode="json", exclude={"request_sha256"}) + ) + return SelectedPostSubmissionEvaluationRequest.model_validate(fields) + + +@dataclass(frozen=True, slots=True) +class ExternalPostSubmitIsolationFacts: + """Observed sandbox identity detached from the trusted service response.""" + + repository: str + platform_manifest_digest: str + platform_manifest_media_type: str + platform_manifest_byte_count: int + operating_system: str + architecture: str + config_image_id: str + runtime: str + isolation_mode: str + sandbox_uid: int + sandbox_gid: int + + +@dataclass(frozen=True, slots=True) +class ExternalPostSubmitMemberFacts: + """One normalized POST image result in exact locked policy order.""" + + order: int + requirement_id: str + registry_entry_id: UUID + registry_entry_digest: str + request_digest: str + result: ExternalCheckerExecutionResult + isolation: ExternalPostSubmitIsolationFacts + + def __post_init__(self) -> None: + if ( + type(self.order) is not int + or self.order < 0 + or not self.requirement_id + or self.result.outcome != "completed" + or self.result.request_digest != self.request_digest + or self.result.registry_entry_id != self.registry_entry_id + or self.result.registry_entry_digest != self.registry_entry_digest + or self.result.phase != "post_submit" + ): + raise ValueError("external POST member facts are invalid") + + +@dataclass(frozen=True, slots=True) +class SelectedPostSubmitExecutionFacts: + """Complete evidence-only result from every locked external POST image.""" + + policy_sha256: str + required_external_count: int + external_members: tuple[ExternalPostSubmitMemberFacts, ...] + + def __post_init__(self) -> None: + members = self.external_members + if ( + type(self.policy_sha256) is not str + or len(self.policy_sha256) != 71 + or not self.policy_sha256.startswith("sha256:") + or type(self.required_external_count) is not int + or self.required_external_count < 0 + or len(members) != self.required_external_count + or tuple(item.order for item in members) != tuple(range(len(members))) + or len({item.requirement_id for item in members}) != len(members) + ): + raise ValueError("selected POST execution facts are invalid") + + def canonical_body(self) -> dict[str, object]: + """Return exact evidence and isolation without any acceptance signal.""" + return { + "schema_version": "workstream.selected_post_submit_execution.v1", + "policy_sha256": self.policy_sha256, + "required_external_count": self.required_external_count, + "external_members": [ + { + "order": member.order, + "requirement_id": member.requirement_id, + "registry_entry_id": str(member.registry_entry_id), + "registry_entry_digest": member.registry_entry_digest, + "request_digest": member.request_digest, + "result": member.result.model_dump(mode="json"), + "isolation": asdict(member.isolation), + } + for member in self.external_members + ], + } + + @property + def result_digest(self) -> str: + """Bind every required image result and observed sandbox fact.""" + return canonical_json_hash(self.canonical_body()) + + +class SelectedPostSubmissionEvaluationResult(CheckerValue): + """Durable selected POST evidence envelope with no acceptance authority.""" + + schema_version: Literal["workstream.selected_post_submit_result.v1"] = ( + "workstream.selected_post_submit_result.v1" + ) + request_id: ResourceId + request_digest: Sha256 + attempt_id: ResourceId + evaluation_generation: VersionNumber + result_id: ResourceId + result_digest: Sha256 + outcome: Literal["completed", "infrastructure_failed"] + execution: SelectedPostSubmitExecutionFacts | None + infrastructure_failure_code: ( + Literal[ + "capacity_exceeded", + "deadline_exceeded", + "material_unavailable", + "implementation_unavailable", + "invalid_output", + ] + | None + ) = None + + @model_validator(mode="after") + def validate_result(self) -> Self: + """Separate complete external evidence from recoverable infrastructure.""" + if self.outcome == "completed": + if self.execution is None or self.infrastructure_failure_code is not None: + raise ValueError("selected POST completed result is invalid") + elif self.execution is not None or self.infrastructure_failure_code is None: + raise ValueError("selected POST infrastructure result is invalid") + if self.result_digest != canonical_json_hash( + self.model_dump(mode="json", exclude={"result_digest"}) + ): + raise ValueError("selected POST result digest mismatch") + return self + + def validate_request( + self, request: SelectedPostSubmissionEvaluationRequest + ) -> None: + """Bind result identity and selected policy without claiming currentness.""" + result = SelectedPostSubmissionEvaluationResult.model_validate(self) + request = SelectedPostSubmissionEvaluationRequest.model_validate(request) + expected_members = tuple( + ( + order, + entry.requirement_id, + entry.registry.registry_entry_id, + entry.registry.entry_digest, + entry.registry.image_digest, + ) + for order, entry in enumerate(request.policy.entries) + ) + actual_members = ( + tuple( + ( + member.order, + member.requirement_id, + member.registry_entry_id, + member.registry_entry_digest, + member.isolation.platform_manifest_digest, + ) + for member in result.execution.external_members + ) + if result.execution is not None + else () + ) + if ( + result.request_id, + result.request_digest, + result.evaluation_generation, + ) != ( + request.evaluation_request_id, + request.request_sha256, + request.evaluation_generation, + ) or ( + result.execution is not None + and ( + result.execution.policy_sha256 != request.policy.policy_sha256 + or result.execution.required_external_count != len(expected_members) + or actual_members != expected_members + ) + ): + raise ValueError("selected POST result request mismatch") + + +class SelectedPostSubmitCurrentResultReference(CheckerValue): + """Owner-issued reference to one completed selected POST result.""" + + schema_version: Literal["post_submit_current_result_reference"] = ( + "post_submit_current_result_reference" + ) + request_id: ResourceId + request_digest: Sha256 + attempt_id: ResourceId + evaluation_generation: VersionNumber + result_id: ResourceId + result_digest: Sha256 + + def validate_result(self, result: object) -> None: + """Match exact completed selected result identity.""" + reference = SelectedPostSubmitCurrentResultReference.model_validate(self) + selected = SelectedPostSubmissionEvaluationResult.model_validate(result) + names = ( + "request_id", "request_digest", "attempt_id", + "evaluation_generation", "result_id", "result_digest", + ) + if selected.outcome != "completed" or any( + getattr(reference, key) != getattr(selected, key) for key in names + ): + raise ValueError("selected POST current reference result mismatch") + + +def make_selected_post_submit_result( + **fields: object, +) -> SelectedPostSubmissionEvaluationResult: + """Derive the selected result identity before strict validation.""" + if "result_digest" in fields: + raise ValueError("selected POST result identity is derived") + candidate = SelectedPostSubmissionEvaluationResult.model_construct( + **fields, + result_digest="sha256:" + "0" * 64, + ) + fields["result_digest"] = canonical_json_hash( + candidate.model_dump(mode="json", exclude={"result_digest"}) + ) + return SelectedPostSubmissionEvaluationResult.model_validate(fields) + + +class SelectedPostSubmitInfrastructureUnavailable(RuntimeError): + """Retain a recoverable POST infrastructure category, never a work verdict.""" + + def __init__(self, code: str) -> None: + if code not in { + "capacity_exceeded", + "deadline_exceeded", + "material_unavailable", + "implementation_unavailable", + "invalid_output", + }: + raise ValueError("selected POST infrastructure failure is invalid") + self.code = code + super().__init__(code) + + +__all__ = ( + "ExternalPostSubmitIsolationFacts", + "ExternalPostSubmitMemberFacts", + "SelectedPostSubmitExecutionFacts", + "SelectedPostSubmitCurrentResultReference", + "SelectedPostSubmitInfrastructureUnavailable", + "SelectedPostSubmitLockedContext", + "SelectedPostSubmissionContent", + "SelectedPostSubmissionEvaluationRequest", + "SelectedPostSubmissionEvaluationResult", + "make_selected_post_submit_request", + "make_selected_post_submit_result", +) diff --git a/backend/app/modules/checkers/api/selected_pre_submit.py b/backend/app/modules/checkers/api/selected_pre_submit.py new file mode 100644 index 000000000..31ad2323a --- /dev/null +++ b/backend/app/modules/checkers/api/selected_pre_submit.py @@ -0,0 +1,910 @@ +"""Closed facts from the selected default-plus-external PRE execution.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +from contextlib import AbstractAsyncContextManager +from typing import Literal, Protocol +from uuid import UUID + +from app.core.hashing import canonical_json_hash +from app.modules.checkers.api.default_checker import WORKSTREAM_DEFAULT_BEHAVIOR_SHA256 +from app.modules.checkers.api.default_checker import WorkstreamDefaultCheckerResult +from app.modules.checkers.api.external import ExternalCheckerExecutionResult + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitAdmissionLineage: + """Immutable TASK, PROJECT, packet and manifest facts needed after verification.""" + + predecessor_submission_id: UUID | None + predecessor_submission_version: int | None + checker_remediation_manifest_id: UUID | None + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_sha256: str + locked_guide_sha256: str + effective_policy_id: UUID + effective_policy_sha256: str + pre_submit_policy_id: UUID + pre_submit_policy_sha256: str + effective_plan_sha256: str + rules_sha256: str + external_checker_policies_sha256: str + post_submit_policy_sha256: str + post_submit_policy_body: dict[str, object] + locked_policy_context_hash: str + semantic_manifest_id: UUID + semantic_manifest_body: dict[str, object] + packet_sha256: str + storage_scheme: str + + def __post_init__(self) -> None: + identifiers = ( + self.guide_id, + self.source_snapshot_id, + self.effective_policy_id, + self.pre_submit_policy_id, + self.semantic_manifest_id, + ) + digests = ( + self.source_snapshot_sha256, + self.locked_guide_sha256, + self.effective_policy_sha256, + self.pre_submit_policy_sha256, + self.effective_plan_sha256, + self.rules_sha256, + self.external_checker_policies_sha256, + self.post_submit_policy_sha256, + self.locked_policy_context_hash, + self.packet_sha256, + ) + if ( + any(type(value) is not UUID for value in identifiers) + or (self.predecessor_submission_id is None) + != (self.predecessor_submission_version is None) + or ( + self.predecessor_submission_id is not None + and type(self.predecessor_submission_id) is not UUID + ) + or ( + self.predecessor_submission_version is not None + and ( + type(self.predecessor_submission_version) is not int + or self.predecessor_submission_version < 1 + ) + ) + or (self.predecessor_submission_id is None) + != (self.checker_remediation_manifest_id is None) + or ( + self.checker_remediation_manifest_id is not None + and type(self.checker_remediation_manifest_id) is not UUID + ) + or not self.guide_version.strip() + or any( + type(value) is not str or len(value) != 71 or not value.startswith("sha256:") + for value in digests + ) + or type(self.semantic_manifest_body) is not dict + or type(self.post_submit_policy_body) is not dict + or canonical_json_hash( + { + key: value + for key, value in self.post_submit_policy_body.items() + if key != "policy_sha256" + } + ) + != self.post_submit_policy_sha256 + or self.post_submit_policy_body.get("policy_sha256") != self.post_submit_policy_sha256 + or self.storage_scheme not in {"local", "s3"} + ): + raise ValueError("selected PRE admission lineage is invalid") + + def canonical_body(self) -> dict[str, object]: + """Return every fact later copied into verified admission custody.""" + return { + "predecessor_submission_id": ( + str(self.predecessor_submission_id) + if self.predecessor_submission_id is not None + else None + ), + "predecessor_submission_version": self.predecessor_submission_version, + "checker_remediation_manifest_id": ( + str(self.checker_remediation_manifest_id) + if self.checker_remediation_manifest_id is not None + else None + ), + "guide_id": str(self.guide_id), + "guide_version": self.guide_version, + "source_snapshot_id": str(self.source_snapshot_id), + "source_snapshot_sha256": self.source_snapshot_sha256, + "locked_guide_sha256": self.locked_guide_sha256, + "effective_policy_id": str(self.effective_policy_id), + "effective_policy_sha256": self.effective_policy_sha256, + "pre_submit_policy_id": str(self.pre_submit_policy_id), + "pre_submit_policy_sha256": self.pre_submit_policy_sha256, + "effective_plan_sha256": self.effective_plan_sha256, + "rules_sha256": self.rules_sha256, + "external_checker_policies_sha256": (self.external_checker_policies_sha256), + "post_submit_policy_sha256": self.post_submit_policy_sha256, + "post_submit_policy_body": self.post_submit_policy_body, + "locked_policy_context_hash": self.locked_policy_context_hash, + "semantic_manifest_id": str(self.semantic_manifest_id), + "semantic_manifest_body": self.semantic_manifest_body, + "packet_sha256": self.packet_sha256, + "storage_scheme": self.storage_scheme, + } + + @classmethod + def from_canonical_body(cls, body: object) -> SelectedPreSubmitAdmissionLineage: + """Parse the exact closed body copied into ART's durable intent.""" + if type(body) is not dict or set(body) != { + "predecessor_submission_id", + "predecessor_submission_version", + "checker_remediation_manifest_id", + "guide_id", + "guide_version", + "source_snapshot_id", + "source_snapshot_sha256", + "locked_guide_sha256", + "effective_policy_id", + "effective_policy_sha256", + "pre_submit_policy_id", + "pre_submit_policy_sha256", + "effective_plan_sha256", + "rules_sha256", + "external_checker_policies_sha256", + "post_submit_policy_sha256", + "post_submit_policy_body", + "locked_policy_context_hash", + "semantic_manifest_id", + "semantic_manifest_body", + "packet_sha256", + "storage_scheme", + }: + raise ValueError("selected PRE admission lineage is invalid") + predecessor = body["predecessor_submission_id"] + return cls( + predecessor_submission_id=(UUID(str(predecessor)) if predecessor is not None else None), + predecessor_submission_version=body["predecessor_submission_version"], + checker_remediation_manifest_id=( + UUID(str(body["checker_remediation_manifest_id"])) + if body["checker_remediation_manifest_id"] is not None + else None + ), + guide_id=UUID(str(body["guide_id"])), + guide_version=body["guide_version"], + source_snapshot_id=UUID(str(body["source_snapshot_id"])), + source_snapshot_sha256=body["source_snapshot_sha256"], + locked_guide_sha256=body["locked_guide_sha256"], + effective_policy_id=UUID(str(body["effective_policy_id"])), + effective_policy_sha256=body["effective_policy_sha256"], + pre_submit_policy_id=UUID(str(body["pre_submit_policy_id"])), + pre_submit_policy_sha256=body["pre_submit_policy_sha256"], + effective_plan_sha256=body["effective_plan_sha256"], + rules_sha256=body["rules_sha256"], + external_checker_policies_sha256=body["external_checker_policies_sha256"], + post_submit_policy_sha256=body["post_submit_policy_sha256"], + post_submit_policy_body=body["post_submit_policy_body"], + locked_policy_context_hash=body["locked_policy_context_hash"], + semantic_manifest_id=UUID(str(body["semantic_manifest_id"])), + semantic_manifest_body=body["semantic_manifest_body"], + packet_sha256=body["packet_sha256"], + storage_scheme=body["storage_scheme"], + ) + + +@dataclass(frozen=True, slots=True) +class ExternalPreSubmitIsolationFacts: + """Observed sandbox identity detached from the trusted service response.""" + + repository: str + platform_manifest_digest: str + platform_manifest_media_type: str + platform_manifest_byte_count: int + operating_system: str + architecture: str + config_image_id: str + runtime: str + isolation_mode: str + sandbox_uid: int + sandbox_gid: int + + +@dataclass(frozen=True, slots=True) +class ExternalPreSubmitMemberFacts: + """One completed required project PRE image in canonical policy order.""" + + order: int + requirement_id: str + registry_entry_id: UUID + registry_entry_digest: str + request_digest: str + result: ExternalCheckerExecutionResult + isolation: ExternalPreSubmitIsolationFacts + + def __post_init__(self) -> None: + if ( + type(self.order) is not int + or self.order < 0 + or not self.requirement_id + or self.result.outcome != "completed" + or self.result.request_digest != self.request_digest + or self.result.registry_entry_id != self.registry_entry_id + or self.result.registry_entry_digest != self.registry_entry_digest + ): + raise ValueError("external PRE member facts are invalid") + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitExecutionFacts: + """One default result followed by the executed required external prefix.""" + + policy_sha256: str + workstream_default: WorkstreamDefaultCheckerResult + required_external_count: int + external_members: tuple[ExternalPreSubmitMemberFacts, ...] + eligible: bool + + def __post_init__(self) -> None: + members = self.external_members + if ( + not self.policy_sha256.startswith("sha256:") + or len(self.policy_sha256) != 71 + or type(self.required_external_count) is not int + or self.required_external_count < 0 + or tuple(item.order for item in members) != tuple(range(len(members))) + or len({item.requirement_id for item in members}) != len(members) + or len(members) > self.required_external_count + ): + raise ValueError("selected PRE execution facts are invalid") + external_passed = all(item.result.verdict == "passed" for item in members) + complete = len(members) == self.required_external_count + if self.eligible != (self.workstream_default.eligible and complete and external_passed): + raise ValueError("selected PRE eligibility differs from required results") + if ( + not self.workstream_default.eligible + and members + or any(item.result.verdict == "failed" for item in members[:-1]) + ): + raise ValueError("selected PRE execution continued after a blocker") + + def canonical_body(self) -> dict[str, object]: + """Return the complete bounded result body retained by the durable owner.""" + return { + "schema_version": "workstream.selected_pre_submit_execution.v1", + "policy_sha256": self.policy_sha256, + "workstream_default": self.workstream_default.model_dump(mode="json"), + "required_external_count": self.required_external_count, + "external_members": [ + { + "order": member.order, + "requirement_id": member.requirement_id, + "registry_entry_id": str(member.registry_entry_id), + "registry_entry_digest": member.registry_entry_digest, + "request_digest": member.request_digest, + "result": member.result.model_dump(mode="json"), + "isolation": asdict(member.isolation), + } + for member in self.external_members + ], + "eligible": self.eligible, + } + + @classmethod + def from_canonical_body(cls, body: object) -> SelectedPreSubmitExecutionFacts: + """Parse the exact bounded result retained for idempotent work replay.""" + if ( + type(body) is not dict + or set(body) + != { + "schema_version", + "policy_sha256", + "workstream_default", + "required_external_count", + "external_members", + "eligible", + } + or body["schema_version"] != "workstream.selected_pre_submit_execution.v1" + ): + raise ValueError("selected PRE execution facts are invalid") + members = body["external_members"] + if type(members) is not list: + raise ValueError("selected PRE execution facts are invalid") + return cls( + policy_sha256=body["policy_sha256"], + workstream_default=WorkstreamDefaultCheckerResult.model_validate( + body["workstream_default"] + ), + required_external_count=body["required_external_count"], + external_members=tuple( + ExternalPreSubmitMemberFacts( + order=item["order"], + requirement_id=item["requirement_id"], + registry_entry_id=UUID(str(item["registry_entry_id"])), + registry_entry_digest=item["registry_entry_digest"], + request_digest=item["request_digest"], + result=ExternalCheckerExecutionResult.model_validate( + item["result"] + | {"registry_entry_id": UUID(str(item["result"]["registry_entry_id"]))} + ), + isolation=ExternalPreSubmitIsolationFacts(**item["isolation"]), + ) + for item in members + ), + eligible=body["eligible"], + ) + + @property + def result_digest(self) -> str: + """Bind every default, project-image and observed-isolation result fact.""" + return canonical_json_hash(self.canonical_body()) + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitReservationFacts: + """Complete durable PRE request reserved before any external work starts.""" + + actor_profile_id: UUID + identity_link_id: UUID + project_id: UUID + task_id: UUID + assignment_id: UUID + attempt_id: UUID + idempotency_key: UUID + attempt_request_digest: str + prepared_generation_id: UUID + policy_sha256: str + default_behavior_sha256: str + default_input_sha256: str + archive_sha256: str + archive_byte_count: int + semantic_manifest_sha256: str + execution_budget_ms: int + admission_lineage: SelectedPreSubmitAdmissionLineage | None = None + + def __post_init__(self) -> None: + """Reject partial authority, material, policy or default-input custody.""" + identifiers = ( + self.actor_profile_id, + self.identity_link_id, + self.project_id, + self.task_id, + self.assignment_id, + self.attempt_id, + self.idempotency_key, + self.prepared_generation_id, + ) + digests = ( + self.attempt_request_digest, + self.policy_sha256, + self.default_behavior_sha256, + self.default_input_sha256, + self.archive_sha256, + self.semantic_manifest_sha256, + ) + if ( + any(type(value) is not UUID for value in identifiers) + or any( + type(value) is not str or len(value) != 71 or not value.startswith("sha256:") + for value in digests + ) + or self.default_behavior_sha256 != WORKSTREAM_DEFAULT_BEHAVIOR_SHA256 + or type(self.archive_byte_count) is not int + or self.archive_byte_count < 0 + or type(self.execution_budget_ms) is not int + or not 100 <= self.execution_budget_ms <= 14_400_000 + or ( + self.admission_lineage is not None + and type(self.admission_lineage) is not SelectedPreSubmitAdmissionLineage + ) + or ( + self.admission_lineage is not None + and ( + canonical_json_hash(self.admission_lineage.semantic_manifest_body) + != self.semantic_manifest_sha256 + or self.admission_lineage.effective_plan_sha256 != self.policy_sha256 + ) + ) + ): + raise ValueError("selected PRE reservation facts are invalid") + + def canonical_body(self) -> dict[str, object]: + """Bind the actual contributor, task, ART generation, policy and bytes.""" + return { + "schema_version": "workstream.selected_pre_submit_reservation.v1", + "actor_profile_id": str(self.actor_profile_id), + "identity_link_id": str(self.identity_link_id), + "project_id": str(self.project_id), + "task_id": str(self.task_id), + "assignment_id": str(self.assignment_id), + "attempt_id": str(self.attempt_id), + "idempotency_key": str(self.idempotency_key), + "attempt_request_digest": self.attempt_request_digest, + "prepared_generation_id": str(self.prepared_generation_id), + "policy_sha256": self.policy_sha256, + "default_behavior_sha256": self.default_behavior_sha256, + "default_input_sha256": self.default_input_sha256, + "archive_sha256": self.archive_sha256, + "archive_byte_count": self.archive_byte_count, + "semantic_manifest_sha256": self.semantic_manifest_sha256, + "execution_budget_ms": self.execution_budget_ms, + "admission_lineage": ( + self.admission_lineage.canonical_body() + if self.admission_lineage is not None + else None + ), + } + + @classmethod + def from_canonical_body(cls, body: object) -> SelectedPreSubmitReservationFacts: + """Recover and validate the durable request snapshot at ART publication.""" + if ( + type(body) is not dict + or set(body) + != { + "schema_version", + "actor_profile_id", + "identity_link_id", + "project_id", + "task_id", + "assignment_id", + "attempt_id", + "idempotency_key", + "attempt_request_digest", + "prepared_generation_id", + "policy_sha256", + "default_behavior_sha256", + "default_input_sha256", + "archive_sha256", + "archive_byte_count", + "semantic_manifest_sha256", + "execution_budget_ms", + "admission_lineage", + } + or body["schema_version"] != "workstream.selected_pre_submit_reservation.v1" + ): + raise ValueError("selected PRE reservation facts are invalid") + lineage_body = body["admission_lineage"] + return cls( + actor_profile_id=UUID(str(body["actor_profile_id"])), + identity_link_id=UUID(str(body["identity_link_id"])), + project_id=UUID(str(body["project_id"])), + task_id=UUID(str(body["task_id"])), + assignment_id=UUID(str(body["assignment_id"])), + attempt_id=UUID(str(body["attempt_id"])), + idempotency_key=UUID(str(body["idempotency_key"])), + attempt_request_digest=body["attempt_request_digest"], + prepared_generation_id=UUID(str(body["prepared_generation_id"])), + policy_sha256=body["policy_sha256"], + default_behavior_sha256=body["default_behavior_sha256"], + default_input_sha256=body["default_input_sha256"], + archive_sha256=body["archive_sha256"], + archive_byte_count=body["archive_byte_count"], + semantic_manifest_sha256=body["semantic_manifest_sha256"], + execution_budget_ms=body["execution_budget_ms"], + admission_lineage=( + SelectedPreSubmitAdmissionLineage.from_canonical_body(lineage_body) + if lineage_body is not None + else None + ), + ) + + @property + def request_digest(self) -> str: + """Return the immutable request identity used by reserve and replay.""" + return canonical_json_hash(self.canonical_body()) + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitLeaseFacts: + """Database-timed generation for one genuine pre-admission ART attempt.""" + + reservation: SelectedPreSubmitReservationFacts + lease_id: UUID + lease_generation: int + lease_expires_at: datetime + + def __post_init__(self) -> None: + """Reject a copied identifier or an unbounded/non-current lease shape.""" + if ( + type(self.reservation) is not SelectedPreSubmitReservationFacts + or type(self.lease_id) is not UUID + or type(self.lease_generation) is not int + or self.lease_generation < 1 + or self.lease_expires_at.tzinfo is None + ): + raise ValueError("selected PRE lease facts are invalid") + + +class SelectedPreSubmitExecutionAuthority(Protocol): + """Process-local capability for one committed durable PRE lease.""" + + def consume( + self, + *, + run_id: UUID, + lease: SelectedPreSubmitLeaseFacts, + ) -> SelectedPreSubmitLeaseFacts: + """Return the exact lease once, or refuse stale/substituted execution.""" + + +class SelectedPreSubmitAdmissionAuthority(Protocol): + """Process-local authority for immediate selected admission.""" + + def consume( + self, + *, + run_id: UUID, + terminal_digest: str, + attempt_id: UUID, + prepared_generation_id: UUID, + archive_sha256: str, + semantic_manifest_sha256: str, + ) -> UUID: + """Consume only for the exact pass and still-live prepared bytes.""" + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitExecuteFacts: + """Exact committed lease consumed by the fixed PRE executor.""" + + lease: SelectedPreSubmitLeaseFacts + + def __post_init__(self) -> None: + if type(self.lease) is not SelectedPreSubmitLeaseFacts: + raise ValueError("selected PRE execute facts are invalid") + + @property + def facts_digest(self) -> str: + """Bind reservation, lease generation and database expiry.""" + return canonical_json_hash( + { + "schema_version": "workstream.selected_pre_submit_execute.v1", + "reservation": self.lease.reservation.canonical_body(), + "lease_id": str(self.lease.lease_id), + "lease_generation": self.lease.lease_generation, + "lease_expires_at": self.lease.lease_expires_at.isoformat(), + } + ) + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitFinalizeFacts: + """Deterministic terminal input consumed before database-timed finalization.""" + + lease: SelectedPreSubmitLeaseFacts + execution: SelectedPreSubmitExecutionFacts | None + infrastructure_failure_code: str | None + + def __post_init__(self) -> None: + if type(self.lease) is not SelectedPreSubmitLeaseFacts: + raise ValueError("selected PRE finalize lease is invalid") + if (self.execution is None) == (self.infrastructure_failure_code is None): + raise ValueError("selected PRE finalize outcome is invalid") + if self.execution is not None: + if type(self.execution) is not SelectedPreSubmitExecutionFacts: + raise ValueError("selected PRE finalize execution is invalid") + if self.execution.policy_sha256 != self.lease.reservation.policy_sha256: + raise ValueError("selected PRE finalize policy is invalid") + else: + SelectedPreSubmitInfrastructureUnavailable(self.infrastructure_failure_code or "") + + @property + def facts_digest(self) -> str: + """Bind the lease to either exact results or one closed infrastructure code.""" + return canonical_json_hash( + { + "schema_version": "workstream.selected_pre_submit_finalize.v1", + "execute_facts_digest": SelectedPreSubmitExecuteFacts(self.lease).facts_digest, + "execution": ( + self.execution.canonical_body() if self.execution is not None else None + ), + "infrastructure_failure_code": self.infrastructure_failure_code, + } + ) + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitAuthorityEvidence: + """One retained AUTH decision bound to exact phase facts.""" + + evidence_id: UUID + facts_digest: str + + def __post_init__(self) -> None: + if ( + type(self.evidence_id) is not UUID + or len(self.facts_digest) != 71 + or not self.facts_digest.startswith("sha256:") + ): + raise ValueError("selected PRE authority evidence is invalid") + + +class PreparedSelectedPreSubmitExecution(Protocol): + """Transaction-bound fixed-service execution authority.""" + + async def consume( + self, facts: SelectedPreSubmitExecuteFacts + ) -> SelectedPreSubmitAuthorityEvidence: + """Consume the exact committed lease once.""" + + +class PreparedSelectedPreSubmitFinalization(Protocol): + """Transaction-bound fixed-service finalization authority.""" + + async def consume( + self, facts: SelectedPreSubmitFinalizeFacts + ) -> SelectedPreSubmitAuthorityEvidence: + """Consume the exact result or infrastructure outcome once.""" + + +class SelectedPreSubmitAuthorityPort(Protocol): + """Prepare separate execute and finalize authority before owner locks.""" + + def prepare_execution( + self, reservation: SelectedPreSubmitReservationFacts + ) -> AbstractAsyncContextManager[PreparedSelectedPreSubmitExecution]: + """Prepare execution authority before TASK/CHECKERS locks in the claim root.""" + + def prepare_finalization( + self, facts: SelectedPreSubmitFinalizeFacts + ) -> AbstractAsyncContextManager[PreparedSelectedPreSubmitFinalization]: + """Prepare exact finalization authority in the terminal root.""" + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitCompletion: + """Terminal facts and immediate admission authority from finalization.""" + + terminal: SelectedPreSubmitTerminalFacts + capability: SelectedPreSubmitAdmissionAuthority | None + + +class SelectedPreSubmitCoordinationPort(Protocol): + """CHECKERS-owned durable lease operations used by the ART workflow.""" + + async def read_current( + self, facts: SelectedPreSubmitReservationFacts + ) -> SelectedPreSubmitCurrentFacts | None: + """Read one exact actor/key result after TASK custody is locked.""" + + async def reserve(self, facts: SelectedPreSubmitReservationFacts) -> UUID: + """Reserve one exact actor/key request after TASK custody is locked.""" + + async def claim( + self, + *, + run_id: UUID, + facts: SelectedPreSubmitReservationFacts, + authority: PreparedSelectedPreSubmitExecution, + ) -> SelectedPreSubmitLeaseFacts: + """Claim one queued run under TASK-first custody.""" + + async def committed_execution( + self, *, run_id: UUID, lease: SelectedPreSubmitLeaseFacts + ) -> SelectedPreSubmitExecutionAuthority: + """Mint process-local execution authority for a committed lease.""" + + async def read_expired_lease( + self, *, run_id: UUID, facts: SelectedPreSubmitReservationFacts + ) -> SelectedPreSubmitLeaseFacts: + """Read one expired committed lease under TASK-first custody.""" + + async def finalize( + self, + *, + run_id: UUID, + lease: SelectedPreSubmitLeaseFacts, + authority: PreparedSelectedPreSubmitFinalization, + execution: SelectedPreSubmitExecutionFacts | None, + infrastructure_failure_code: str | None, + ) -> SelectedPreSubmitCompletion: + """Persist one terminal result and return pass authority when eligible.""" + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitTerminalFacts: + """Exact terminal value finalized under a fresh TASK-first transaction.""" + + lease: SelectedPreSubmitLeaseFacts + outcome: Literal["completed", "infrastructure_failed"] + execution: SelectedPreSubmitExecutionFacts | None + infrastructure_failure_code: str | None + queued_at: datetime + started_at: datetime + completed_at: datetime + + def __post_init__(self) -> None: + """Keep work findings separate from recoverable infrastructure outcomes.""" + if type(self.lease) is not SelectedPreSubmitLeaseFacts: + raise ValueError("selected PRE terminal lease is invalid") + if any( + value.tzinfo is None + for value in ( + self.queued_at, + self.started_at, + self.completed_at, + ) + ) or not (self.queued_at <= self.started_at <= self.completed_at): + raise ValueError("selected PRE terminal timing is invalid") + if self.outcome == "completed": + if ( + type(self.execution) is not SelectedPreSubmitExecutionFacts + or self.infrastructure_failure_code is not None + or self.execution.policy_sha256 != self.lease.reservation.policy_sha256 + ): + raise ValueError("selected PRE completed outcome is invalid") + elif self.execution is not None or self.infrastructure_failure_code not in { + "capacity_exceeded", + "deadline_exceeded", + "material_unavailable", + "implementation_unavailable", + "invalid_output", + }: + raise ValueError("selected PRE infrastructure outcome is invalid") + + def canonical_body(self) -> dict[str, object]: + """Return persisted latency, lease and terminal facts without private paths.""" + return { + "schema_version": "workstream.selected_pre_submit_terminal.v1", + "reservation": self.lease.reservation.canonical_body(), + "request_digest": self.lease.reservation.request_digest, + "lease_id": str(self.lease.lease_id), + "lease_generation": self.lease.lease_generation, + "lease_expires_at": self.lease.lease_expires_at.isoformat(), + "outcome": self.outcome, + "execution": self.execution.canonical_body() if self.execution else None, + "infrastructure_failure_code": self.infrastructure_failure_code, + "queued_at": self.queued_at.isoformat(), + "started_at": self.started_at.isoformat(), + "completed_at": self.completed_at.isoformat(), + "queue_latency_ms": int((self.started_at - self.queued_at).total_seconds() * 1000), + "execution_latency_ms": int( + (self.completed_at - self.started_at).total_seconds() * 1000 + ), + } + + @classmethod + def from_canonical_body(cls, body: object) -> SelectedPreSubmitTerminalFacts: + """Parse and revalidate every persisted lease, result and timing fact.""" + if ( + type(body) is not dict + or set(body) + != { + "schema_version", + "reservation", + "request_digest", + "lease_id", + "lease_generation", + "lease_expires_at", + "outcome", + "execution", + "infrastructure_failure_code", + "queued_at", + "started_at", + "completed_at", + "queue_latency_ms", + "execution_latency_ms", + } + or body["schema_version"] != "workstream.selected_pre_submit_terminal.v1" + ): + raise ValueError("selected PRE terminal facts are invalid") + reservation = SelectedPreSubmitReservationFacts.from_canonical_body(body["reservation"]) + if body["request_digest"] != reservation.request_digest: + raise ValueError("selected PRE terminal request is invalid") + execution_body = body["execution"] + value = cls( + lease=SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=UUID(str(body["lease_id"])), + lease_generation=body["lease_generation"], + lease_expires_at=datetime.fromisoformat(body["lease_expires_at"]), + ), + outcome=body["outcome"], + execution=( + SelectedPreSubmitExecutionFacts.from_canonical_body(execution_body) + if execution_body is not None + else None + ), + infrastructure_failure_code=body["infrastructure_failure_code"], + queued_at=datetime.fromisoformat(body["queued_at"]), + started_at=datetime.fromisoformat(body["started_at"]), + completed_at=datetime.fromisoformat(body["completed_at"]), + ) + if value.canonical_body() != body: + raise ValueError("selected PRE terminal facts are invalid") + return value + + @property + def terminal_digest(self) -> str: + """Bind the exact terminal body used by replay and admission.""" + return canonical_json_hash(self.canonical_body()) + + +@dataclass(frozen=True, slots=True) +class SelectedPreSubmitCurrentFacts: + """One exact actor/key namespace result safe for bounded ART replay.""" + + run_id: UUID + reservation: SelectedPreSubmitReservationFacts + status: Literal["queued", "running", "completed", "infrastructure_failed"] + terminal: SelectedPreSubmitTerminalFacts | None + + def __post_init__(self) -> None: + terminal_status = self.terminal.outcome if self.terminal is not None else None + if ( + type(self.run_id) is not UUID + or type(self.reservation) is not SelectedPreSubmitReservationFacts + or self.run_id != self.reservation.attempt_id + or self.status + not in { + "queued", + "running", + "completed", + "infrastructure_failed", + } + or (self.status in {"queued", "running"}) != (self.terminal is None) + or ( + self.terminal is not None + and ( + self.terminal.lease.reservation != self.reservation + or terminal_status != self.status + ) + ) + ): + raise ValueError("selected PRE current facts are invalid") + + +class SelectedPreSubmitInfrastructureUnavailable(RuntimeError): + """Retain a recoverable PRE infrastructure category, never a work failure.""" + + def __init__(self, code: str) -> None: + if code not in { + "capacity_exceeded", + "deadline_exceeded", + "material_unavailable", + "implementation_unavailable", + "invalid_output", + }: + raise ValueError("selected PRE infrastructure failure is invalid") + self.code = code + super().__init__(code) + + +class SelectedPreSubmitRequestConflict(RuntimeError): + """Refuse one actor/key namespace reused with changed stable facts.""" + + +class SelectedPreSubmitUnavailable(RuntimeError): + """Conceal absent, stale or conflicting durable selected PRE custody.""" + + +class SelectedPreSubmitAuthorityUnavailable(RuntimeError): + """Conceal fixed-service PRE authorization loss without product facts.""" + + +__all__ = ( + "ExternalPreSubmitIsolationFacts", + "ExternalPreSubmitMemberFacts", + "PreparedSelectedPreSubmitExecution", + "PreparedSelectedPreSubmitFinalization", + "SelectedPreSubmitAdmissionAuthority", + "SelectedPreSubmitAuthorityEvidence", + "SelectedPreSubmitAuthorityUnavailable", + "SelectedPreSubmitAuthorityPort", + "SelectedPreSubmitCompletion", + "SelectedPreSubmitCoordinationPort", + "SelectedPreSubmitCurrentFacts", + "SelectedPreSubmitExecuteFacts", + "SelectedPreSubmitExecutionFacts", + "SelectedPreSubmitInfrastructureUnavailable", + "SelectedPreSubmitFinalizeFacts", + "SelectedPreSubmitLeaseFacts", + "SelectedPreSubmitReservationFacts", + "SelectedPreSubmitRequestConflict", + "SelectedPreSubmitTerminalFacts", + "SelectedPreSubmitUnavailable", +) diff --git a/backend/app/modules/checkers/catalogue.py b/backend/app/modules/checkers/catalogue.py deleted file mode 100644 index fac8b96dd..000000000 --- a/backend/app/modules/checkers/catalogue.py +++ /dev/null @@ -1,730 +0,0 @@ -"""Single immutable catalogue for pre-submission checker planning.""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -from enum import StrEnum -from types import MappingProxyType -from typing import Any, Mapping - -from app.modules.checkers.api.pre_submit_catalogue import ( - PreSubmissionCapabilityDefinition, PreSubmissionCapabilityProjection, -) -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api.policy_compilation import CompiledPreSubmitCheckerPolicy -from app.modules.checkers.api import ( - EffectivePreSubmissionExecutionPlan, - EffectivePreSubmissionPlanLineage, -) - - -PRE_SUBMISSION_CATALOGUE_ID = "workstream.pre_submission_checkers" -PRE_SUBMISSION_CATALOGUE_VERSION = "v0.1" -PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION = "pre_submission_checker_catalogue.v1" -PRE_SUBMISSION_RESULT_SCHEMA_VERSION = "pre_submission_checker_result.v1" - - -class PreSubmissionCatalogueError(ValueError): - """Reject an invalid or ambiguous catalogue configuration.""" - - -class PreSubmissionCheckerClassification(StrEnum): - """Closed operational classification for one catalogue definition.""" - - MANDATORY_SECURITY = "mandatory_security" - MANDATORY_INTEGRITY = "mandatory_integrity" - MANDATORY_ACCOUNTABILITY = "mandatory_accountability" - ADVISORY = "advisory" - - @property - def mandatory(self) -> bool: - """Return whether disabling this definition makes intake unavailable.""" - return self is not PreSubmissionCheckerClassification.ADVISORY - - -class PreSubmissionCheckerPhase(StrEnum): - """Canonical execution-plan phases.""" - - CUSTODY = "custody" - IDENTITY = "identity" - MATERIALIZATION = "materialization" - DEFAULT_POLICY = "default_policy" - PROJECT_POLICY = "project_policy" - - -_PHASE_ORDER = {phase: index for index, phase in enumerate(PreSubmissionCheckerPhase)} - - -def pre_submission_phase_order(phase: PreSubmissionCheckerPhase) -> int: - """Return the canonical ordinal for one pre-submission phase.""" - return _PHASE_ORDER[phase] - - -class PreSubmissionCheckerState(StrEnum): - ENABLED = "enabled" - DISABLED = "disabled" - - -class PreSubmissionDisabledBehavior(StrEnum): - INFRASTRUCTURE_UNAVAILABLE = "infrastructure_unavailable" - RECORD_DISABLED_AND_CONTINUE = "record_disabled_and_continue" - - -class PreSubmissionDispatchKind(StrEnum): - PLATFORM_CAPABILITY = "platform_capability" - POLICY_PRIMITIVE = "policy_primitive" - - -class PreSubmissionPlatformCapability(StrEnum): - OUTER_ZIP_VALID = "submission_archive.outer_zip_valid" - PATHS_SAFE = "submission_archive.paths_safe" - ENTRIES_SAFE = "submission_archive.entries_safe" - RESOURCES_BOUNDED = "submission_archive.resources_bounded" - INTEGRITY_VERIFIED = "submission_archive.integrity_verified" - ARCHIVE_IDENTITY = "artifact_commitment.identity" - MANIFEST_IDENTITY = "submission_manifest.semantic_identity" - EXECUTABLE_NORMALIZED = "submission_manifest.executable_normalized" - CONTENT_CHANGED = "submission_change.changed" - SEALED_TREE_VERIFIED = "submission_materialization.sealed_tree_verified" - SUBMISSION_PACKET = "validate_submission_packet" - ATTESTATION = "require_attestation" - SENSITIVE_PATH = "forbid_high_confidence_sensitive_path" - QUALITY_WARNING = "warn_low_quality_generated_artifact" - - -class PreSubmissionPolicyPrimitive(StrEnum): - FORBID_ARTIFACT = "forbid_artifact" - LIMIT_FILE_SIZE = "limit_file_size" - LIMIT_PACKAGE_SIZE = "limit_package_size" - LIMIT_ARCHIVE_ENTRIES = "limit_archive_entries" - LIMIT_ARCHIVE_SIZE = "limit_archive_size" - ENFORCE_STORAGE_SCHEME = "enforce_storage_scheme" - VERIFY_HASH = "verify_hash" - REQUIRE_ATTESTATION = "require_attestation" - REQUIRE_PACKAGING = "require_packaging" - REQUIRE_FILE = "require_file" - REQUIRE_MINIMUM_EVIDENCE = "require_minimum_evidence" - REQUIRE_MANIFEST_FIELD = "require_manifest_field" - VALIDATE_SUBMISSION_PACKET = "validate_submission_packet" - WARN_LOW_QUALITY_GENERATED_ARTIFACT = "warn_low_quality_generated_artifact" - - -@dataclass(frozen=True, slots=True) -class PreSubmissionCheckerDefinition: - """One closed typed definition in the process-wide catalogue.""" - - stable_id: str - version: str - public_name: str - owner: str - phase: PreSubmissionCheckerPhase - order: int - dependencies: tuple[str, ...] - classification: PreSubmissionCheckerClassification - typed_inputs: tuple[str, ...] - result_schema: str - failure_code: str - resource_budget: tuple[tuple[str, int], ...] - state: PreSubmissionCheckerState - disabled_behavior: PreSubmissionDisabledBehavior - policy_trace_source: str - dispatch_kind: PreSubmissionDispatchKind - dispatch_capability: str - primitive: str | None = None - policy_fields: tuple[str, ...] = () - - def __post_init__(self) -> None: - values = ( - self.stable_id, - self.version, - self.public_name, - self.owner, - self.result_schema, - self.failure_code, - self.policy_trace_source, - self.dispatch_capability, - ) - if any(type(value) is not str or not value for value in values): - raise PreSubmissionCatalogueError("catalogue definition identity is invalid") - if type(self.order) is not int or self.order < 0: - raise PreSubmissionCatalogueError("catalogue definition order is invalid") - if len(self.dependencies) != len(set(self.dependencies)): - raise PreSubmissionCatalogueError("catalogue definition has duplicate dependencies") - if len(self.typed_inputs) != len(set(self.typed_inputs)) or not self.typed_inputs: - raise PreSubmissionCatalogueError("catalogue definition typed inputs are invalid") - if tuple(sorted(self.resource_budget)) != self.resource_budget or any( - type(key) is not str or not key or type(value) is not int or value < 0 - for key, value in self.resource_budget - ): - raise PreSubmissionCatalogueError("catalogue definition resource budget is invalid") - if self.classification.mandatory: - if ( - self.disabled_behavior - is not PreSubmissionDisabledBehavior.INFRASTRUCTURE_UNAVAILABLE - ): - raise PreSubmissionCatalogueError("mandatory catalogue definition may not skip") - elif ( - self.disabled_behavior is not PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE - ): - raise PreSubmissionCatalogueError("advisory catalogue definition may not block startup") - if self.dispatch_kind is PreSubmissionDispatchKind.POLICY_PRIMITIVE: - if not self.primitive or not self.policy_fields: - raise PreSubmissionCatalogueError("policy catalogue definition is incomplete") - if ( - self.primitive not in set(PreSubmissionPolicyPrimitive) - or self.dispatch_capability != self.primitive - ): - raise PreSubmissionCatalogueError("policy catalogue primitive is unknown") - elif self.primitive is not None or self.policy_fields: - raise PreSubmissionCatalogueError( - "platform capability may not masquerade as a primitive" - ) - elif self.dispatch_capability not in set(PreSubmissionPlatformCapability): - raise PreSubmissionCatalogueError("platform catalogue capability is unknown") - - def manifest_entry(self) -> dict[str, Any]: - """Return the canonical immutable catalogue projection.""" - return { - "stable_id": self.stable_id, - "version": self.version, - "public_name": self.public_name, - "owner": self.owner, - "phase": self.phase.value, - "order": self.order, - "dependencies": list(self.dependencies), - "classification": self.classification.value, - "typed_inputs": list(self.typed_inputs), - "result_schema": self.result_schema, - "failure_code": self.failure_code, - "resource_budget": dict(self.resource_budget), - "state": self.state.value, - "disabled_behavior": self.disabled_behavior.value, - "policy_trace_source": self.policy_trace_source, - "dispatch_kind": self.dispatch_kind.value, - "dispatch_capability": self.dispatch_capability, - "primitive": self.primitive, - "policy_fields": list(self.policy_fields), - } - - -@dataclass(frozen=True, slots=True) -class PreSubmissionCheckerCatalogue: - """Validated process-wide catalogue and its canonical identity.""" - - catalogue_id: str - version: str - schema_version: str - entries: tuple[PreSubmissionCheckerDefinition, ...] - - def __post_init__(self) -> None: - if ( - self.catalogue_id != PRE_SUBMISSION_CATALOGUE_ID - or self.version != PRE_SUBMISSION_CATALOGUE_VERSION - or self.schema_version != PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION - ): - raise PreSubmissionCatalogueError("catalogue envelope is invalid") - if not self.entries: - raise PreSubmissionCatalogueError("catalogue requires definitions") - stable_ids = [entry.stable_id for entry in self.entries] - if len(stable_ids) != len(set(stable_ids)): - raise PreSubmissionCatalogueError("catalogue contains duplicate stable IDs") - primitives = [entry.primitive for entry in self.entries if entry.primitive is not None] - if len(primitives) != len(set(primitives)): - raise PreSubmissionCatalogueError("catalogue contains duplicate primitives") - expected_order = tuple(sorted(self.entries, key=_definition_sort_key)) - if self.entries != expected_order: - raise PreSubmissionCatalogueError("catalogue definitions are not canonical") - self._validate_dependencies() - - def _validate_dependencies(self) -> None: - by_id = {entry.stable_id: entry for entry in self.entries} - for entry in self.entries: - for dependency_id in entry.dependencies: - dependency = by_id.get(dependency_id) - if dependency is None: - raise PreSubmissionCatalogueError("catalogue dependency is missing") - if _PHASE_ORDER[dependency.phase] > _PHASE_ORDER[entry.phase]: - raise PreSubmissionCatalogueError("catalogue dependency phase is invalid") - if dependency.phase is entry.phase and dependency.order >= entry.order: - raise PreSubmissionCatalogueError("catalogue dependency order is invalid") - visiting: set[str] = set() - visited: set[str] = set() - - def visit(stable_id: str) -> None: - if stable_id in visiting: - raise PreSubmissionCatalogueError("catalogue dependency cycle detected") - if stable_id in visited: - return - visiting.add(stable_id) - for dependency_id in by_id[stable_id].dependencies: - visit(dependency_id) - visiting.remove(stable_id) - visited.add(stable_id) - - for entry in self.entries: - visit(entry.stable_id) - - @property - def manifest(self) -> Mapping[str, Any]: - """Return an immutable top-level manifest projection.""" - return MappingProxyType( - { - "catalogue_id": self.catalogue_id, - "version": self.version, - "schema_version": self.schema_version, - "entries": [entry.manifest_entry() for entry in self.entries], - } - ) - - @property - def manifest_sha256(self) -> str: - return canonical_json_hash(dict(self.manifest)) - - @property - def available(self) -> bool: - return not any( - entry.classification.mandatory and entry.state is PreSubmissionCheckerState.DISABLED - for entry in self.entries - ) - - def definition(self, stable_id: str) -> PreSubmissionCheckerDefinition: - for entry in self.entries: - if entry.stable_id == stable_id: - return entry - raise PreSubmissionCatalogueError("catalogue definition is unknown") - - def primitive_definition(self, primitive: str) -> PreSubmissionCheckerDefinition: - for entry in self.entries: - if entry.primitive == primitive: - return entry - raise PreSubmissionCatalogueError("catalogue primitive is unknown") - - def compile_policy_bundle( - self, *, effective_policy: Mapping[str, object], effective_policy_hash: str, - ) -> CompiledPreSubmitCheckerPolicy: - """Implement the public policy compiler capability through its sole owner.""" - from app.modules.checkers.compiler import compile_effective_project_submission_artifact_policy - - return compile_effective_project_submission_artifact_policy( - dict(effective_policy), effective_policy_hash, - ) - - def compile_effective_plan( - self, - *, - lineage: EffectivePreSubmissionPlanLineage, - effective_policy: Mapping[str, object], - compiled_bundle: Mapping[str, object], - ) -> EffectivePreSubmissionExecutionPlan: - """Implement the public deterministic CHECKER planning capability.""" - from app.modules.checkers.effective_plan import ( - compile_effective_pre_submission_execution_plan, - ) - - return compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=dict(effective_policy), - compiled_bundle=dict(compiled_bundle), - catalogue=self, - ) - - -def build_pre_submission_checker_catalogue( - *, disabled_entry_ids: frozenset[str] = frozenset() -) -> PreSubmissionCheckerCatalogue: - """Build the sole catalogue with startup-fixed availability state.""" - definitions = _default_definitions() - known = {definition.stable_id for definition in definitions} - if not disabled_entry_ids.issubset(known): - raise PreSubmissionCatalogueError("disabled catalogue definition is unknown") - entries = tuple( - replace(definition, state=PreSubmissionCheckerState.DISABLED) - if definition.stable_id in disabled_entry_ids - else definition - for definition in definitions - ) - return PreSubmissionCheckerCatalogue( - PRE_SUBMISSION_CATALOGUE_ID, - PRE_SUBMISSION_CATALOGUE_VERSION, - PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION, - tuple(sorted(entries, key=_definition_sort_key)), - ) - - -def parse_disabled_pre_submission_checker_ids(raw: str) -> frozenset[str]: - """Parse one startup-owned comma-separated disabled-definition set.""" - if type(raw) is not str: - raise PreSubmissionCatalogueError("disabled catalogue configuration is invalid") - parts = [part.strip() for part in raw.split(",") if part.strip()] - if len(parts) != len(set(parts)): - raise PreSubmissionCatalogueError("disabled catalogue configuration has duplicates") - return frozenset(parts) - - -def _definition_sort_key(entry: PreSubmissionCheckerDefinition) -> tuple[int, int, str]: - return (_PHASE_ORDER[entry.phase], entry.order, entry.stable_id) - - -def _platform( - stable_id: str, - *, - phase: PreSubmissionCheckerPhase, - order: int, - classification: PreSubmissionCheckerClassification, - input_name: str, - capability: str, - failure_code: str, - dependencies: tuple[str, ...] = (), - public_name: str | None = None, -) -> PreSubmissionCheckerDefinition: - return PreSubmissionCheckerDefinition( - stable_id=stable_id, - version="v1", - public_name=public_name or stable_id, - owner="workstream.artifact", - phase=phase, - order=order, - dependencies=dependencies, - classification=classification, - typed_inputs=(input_name,), - result_schema=PRE_SUBMISSION_RESULT_SCHEMA_VERSION, - failure_code=failure_code, - resource_budget=(("maximum_results", 1),), - state=PreSubmissionCheckerState.ENABLED, - disabled_behavior=( - PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE - if classification is PreSubmissionCheckerClassification.ADVISORY - else PreSubmissionDisabledBehavior.INFRASTRUCTURE_UNAVAILABLE - ), - policy_trace_source="workstream_default_policy", - dispatch_kind=PreSubmissionDispatchKind.PLATFORM_CAPABILITY, - dispatch_capability=capability, - ) - - -def _policy( - stable_id: str, - primitive: str, - public_name: str, - policy_fields: tuple[str, ...], - *, - order: int, - classification: PreSubmissionCheckerClassification = PreSubmissionCheckerClassification.MANDATORY_ACCOUNTABILITY, - typed_inputs: tuple[str, ...] = ("LockedProjectCheckerRule", "SubmissionManifestView"), -) -> PreSubmissionCheckerDefinition: - return PreSubmissionCheckerDefinition( - stable_id=stable_id, - version="v1", - public_name=public_name, - owner="workstream.checker_policy", - phase=PreSubmissionCheckerPhase.PROJECT_POLICY, - order=order, - dependencies=("artifact.scratch.sealed_tree_verified",), - classification=classification, - typed_inputs=typed_inputs, - result_schema=PRE_SUBMISSION_RESULT_SCHEMA_VERSION, - failure_code="pre_submission_checker_failed", - resource_budget=(("maximum_results", 1),), - state=PreSubmissionCheckerState.ENABLED, - disabled_behavior=( - PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE - if classification is PreSubmissionCheckerClassification.ADVISORY - else PreSubmissionDisabledBehavior.INFRASTRUCTURE_UNAVAILABLE - ), - policy_trace_source="locked_effective_project_submission_artifact_policy", - dispatch_kind=PreSubmissionDispatchKind.POLICY_PRIMITIVE, - dispatch_capability=primitive, - primitive=primitive, - policy_fields=tuple(sorted(policy_fields)), - ) - - -def _default_definitions() -> tuple[PreSubmissionCheckerDefinition, ...]: - security = PreSubmissionCheckerClassification.MANDATORY_SECURITY - integrity = PreSubmissionCheckerClassification.MANDATORY_INTEGRITY - accountability = PreSubmissionCheckerClassification.MANDATORY_ACCOUNTABILITY - advisory = PreSubmissionCheckerClassification.ADVISORY - custody = PreSubmissionCheckerPhase.CUSTODY - identity = PreSubmissionCheckerPhase.IDENTITY - materialization = PreSubmissionCheckerPhase.MATERIALIZATION - defaults = PreSubmissionCheckerPhase.DEFAULT_POLICY - definitions = ( - _platform( - "artifact.outer_zip.valid", - phase=custody, - order=10, - classification=security, - input_name="SubmissionArchiveInspectionResult", - capability="submission_archive.outer_zip_valid", - failure_code="submission_archive_malformed", - ), - _platform( - "artifact.archive.paths_safe", - phase=custody, - order=20, - classification=security, - input_name="SubmissionArchiveInspectionResult", - capability="submission_archive.paths_safe", - failure_code="submission_archive_unsafe_entry", - dependencies=("artifact.outer_zip.valid",), - ), - _platform( - "artifact.archive.entries_safe", - public_name="Reject encrypted ZIP entries, symbolic links and special files", - phase=custody, - order=30, - classification=security, - input_name="SubmissionArchiveInspectionResult", - capability="submission_archive.entries_safe", - failure_code="submission_archive_unsafe_entry", - dependencies=("artifact.archive.paths_safe",), - ), - _platform( - "artifact.archive.resources_bounded", - phase=custody, - order=40, - classification=security, - input_name="SubmissionArchiveInspectionResult", - capability="submission_archive.resources_bounded", - failure_code="submission_archive_limit_exceeded", - dependencies=("artifact.archive.entries_safe",), - ), - _platform( - "artifact.archive.integrity_verified", - phase=custody, - order=50, - classification=integrity, - input_name="SubmissionArchiveInspectionResult", - capability="submission_archive.integrity_verified", - failure_code="submission_archive_integrity_failure", - dependencies=("artifact.archive.resources_bounded",), - ), - _platform( - "artifact.archive.identity_computed", - phase=identity, - order=10, - classification=integrity, - input_name="ArtifactCommitment", - capability="artifact_commitment.identity", - failure_code="submission_archive_identity_unavailable", - dependencies=("artifact.archive.integrity_verified",), - ), - _platform( - "artifact.manifest.semantic_identity_computed", - phase=identity, - order=20, - classification=integrity, - input_name="SubmissionManifest", - capability="submission_manifest.semantic_identity", - failure_code="submission_manifest_identity_unavailable", - dependencies=("artifact.archive.identity_computed",), - ), - _platform( - "artifact.manifest.executable_normalized", - phase=identity, - order=30, - classification=integrity, - input_name="SubmissionManifest", - capability="submission_manifest.executable_normalized", - failure_code="submission_manifest_executable_invalid", - dependencies=("artifact.manifest.semantic_identity_computed",), - ), - _platform( - "artifact.revision.content_changed", - phase=identity, - order=40, - classification=integrity, - input_name="SubmissionChangeGateResult", - capability="submission_change.changed", - failure_code="submission_manifest_unchanged", - dependencies=("artifact.manifest.executable_normalized",), - ), - _platform( - "artifact.scratch.sealed_tree_verified", - phase=materialization, - order=10, - classification=integrity, - input_name="SealedSubmissionTree", - capability="submission_materialization.sealed_tree_verified", - failure_code="pre_submission_materialization_unavailable", - dependencies=("artifact.revision.content_changed",), - ), - _platform( - "submission.packet.required_fields", - phase=defaults, - order=10, - classification=accountability, - input_name="SubmissionPacket", - capability="validate_submission_packet", - failure_code="pre_submission_packet_invalid", - dependencies=("artifact.scratch.sealed_tree_verified",), - public_name="check_submission_packet", - ), - _platform( - "submission.attestation.required_topics", - phase=defaults, - order=20, - classification=accountability, - input_name="SubmissionPacket", - capability="require_attestation", - failure_code="pre_submission_attestation_missing", - dependencies=("submission.packet.required_fields",), - public_name="check_confidentiality_attestation", - ), - _platform( - "artifact.sensitive_paths.high_confidence", - phase=defaults, - order=30, - classification=security, - input_name="SubmissionManifest", - capability="forbid_high_confidence_sensitive_path", - failure_code="pre_submission_sensitive_path_forbidden", - dependencies=("submission.attestation.required_topics",), - public_name="check_forbidden_files", - ), - _platform( - "artifact.quality.placeholder_signal", - phase=defaults, - order=40, - classification=advisory, - input_name="SubmissionPacket", - capability="warn_low_quality_generated_artifact", - failure_code="pre_submission_quality_warning", - dependencies=("artifact.sensitive_paths.high_confidence",), - public_name="check_low_quality_generated_artifacts", - ), - _policy( - "policy.submission_packet.validate", - "validate_submission_packet", - "check_submission_packet", - ("required_packet_fields",), - order=10, - ), - _policy( - "policy.storage_scheme.enforce", - "enforce_storage_scheme", - "check_evidence_integrity", - ("allowed_storage_schemes",), - order=20, - classification=integrity, - ), - _policy( - "policy.manifest_field.require", - "require_manifest_field", - "check_evidence_integrity", - ("manifest_required",), - order=30, - classification=integrity, - ), - _policy( - "policy.hash.verify", - "verify_hash", - "check_evidence_integrity", - ("artifact_hash_algorithm", "artifact_hash_required"), - order=40, - classification=integrity, - ), - _policy( - "policy.file.require", - "require_file", - "check_required_files", - ("required_artifacts",), - order=50, - ), - _policy( - "policy.evidence.minimum", - "require_minimum_evidence", - "check_evidence_present", - ("required_evidence",), - order=60, - ), - _policy( - "policy.artifact.forbid", - "forbid_artifact", - "check_forbidden_files", - ("forbidden_artifacts",), - order=70, - classification=security, - ), - _policy( - "policy.attestation.require", - "require_attestation", - "check_confidentiality_attestation", - ("attestation_terms",), - order=80, - ), - _policy( - "policy.file_size.limit", - "limit_file_size", - "check_evidence_integrity", - ("maximum_file_size_bytes",), - order=90, - classification=integrity, - ), - _policy( - "policy.package_size.limit", - "limit_package_size", - "check_evidence_integrity", - ("maximum_package_size_bytes",), - order=100, - classification=integrity, - ), - _policy( - "policy.archive_size.limit", - "limit_archive_size", - "check_evidence_integrity", - ("maximum_archive_size_bytes",), - order=102, - classification=integrity, - typed_inputs=("LockedProjectCheckerRule", "ArtifactCommitment"), - ), - _policy( - "policy.archive_entries.limit", - "limit_archive_entries", - "check_evidence_integrity", - ("maximum_archive_entries",), - order=105, - classification=integrity, - ), - _policy( - "policy.packaging.require", - "require_packaging", - "check_submission_packet", - ("packaging",), - order=110, - ), - _policy( - "policy.generated_quality.warn", - "warn_low_quality_generated_artifact", - "check_low_quality_generated_artifacts", - ("workstream_default_policy",), - order=120, - classification=advisory, - ), - ) - return tuple(sorted(definitions, key=_definition_sort_key)) - - -def project_guide_pre_submission_capabilities( - catalogue: PreSubmissionCheckerCatalogue, -) -> PreSubmissionCapabilityProjection: - """Project the exact deployment catalogue without creating policy authority.""" - definitions = tuple( - PreSubmissionCapabilityDefinition( - **entry.manifest_entry(), - selectable=( - entry.state is PreSubmissionCheckerState.ENABLED - and entry.dispatch_kind is PreSubmissionDispatchKind.POLICY_PRIMITIVE - and entry.phase is PreSubmissionCheckerPhase.PROJECT_POLICY - ), - ) - for entry in catalogue.entries - ) - return PreSubmissionCapabilityProjection( - catalogue_id=catalogue.catalogue_id, - version=catalogue.version, - schema_version=catalogue.schema_version, - manifest_sha256=catalogue.manifest_sha256, - available=catalogue.available, - definitions=definitions, - ) diff --git a/backend/app/modules/checkers/checker_retry.py b/backend/app/modules/checkers/checker_retry.py index 4b7e77c97..9130241c7 100644 --- a/backend/app/modules/checkers/checker_retry.py +++ b/backend/app/modules/checkers/checker_retry.py @@ -17,9 +17,8 @@ CheckerRetryPreparation, CheckerRetryResult, CheckerRetrySource, CheckerRetrySuccessor, EvaluationTaskGuard, ) -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, make_post_submit_request, -) +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, @@ -28,7 +27,7 @@ from app.modules.outbox.api import OutboxAppendInput, OutboxAppendPort -def _source_from_run(run: CheckerRun) -> tuple[PostSubmissionEvaluationRequest, CheckerRetrySource]: +def _source_from_run(run: CheckerRun) -> 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 @@ -168,13 +167,14 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: ) handle = await self._authorization.prepare(preparation) try: - next_request = make_post_submit_request(**{ + request_fields = { **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) reserved = await self._evaluations._reserve_after_task_lock( next_request, terminal_source_id=source.source_run_id, ) diff --git a/backend/app/modules/checkers/compiler.py b/backend/app/modules/checkers/compiler.py deleted file mode 100644 index a82b127ae..000000000 --- a/backend/app/modules/checkers/compiler.py +++ /dev/null @@ -1,649 +0,0 @@ -"""Trusted compiler for project pre-submit checker bundles.""" - -from __future__ import annotations - -from functools import lru_cache -from collections.abc import Callable -from typing import Any - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api.policy_compilation import CompiledPreSubmitCheckerPolicy -from app.modules.checkers.catalogue import ( - PreSubmissionCatalogueError, - PreSubmissionCheckerCatalogue, - build_pre_submission_checker_catalogue, -) -from app.modules.checkers.api.artifact_paths import is_canonical_relative_path, required_evidence_path - -PRE_SUBMIT_COMPILER_VERSION = "workstream-pre-submit-compiler-v0.1" -PRE_SUBMIT_BUNDLE_SCHEMA_VERSION = "pre_submit_checker_bundle.v1" -PRE_SUBMIT_SPEC_SCHEMA_VERSION = "pre_submit_checker_spec.v1" -PRIMITIVES_VERSION = "workstream-pre-submit-primitives.v1" - -BLOCKING_SEVERITY = "blocking" -WARNING_SEVERITY = "warning" - - -class PreSubmitCheckerCompilerError(ValueError): - """Raised when checker specification compilation fails closed.""" - - - - -@lru_cache(maxsize=1) -def _compiler_catalogue() -> PreSubmissionCheckerCatalogue: - """Return the immutable base catalogue used for policy compilation.""" - return build_pre_submission_checker_catalogue() - - -def build_project_pre_submit_checker_spec( - effective_policy: dict[str, Any], - effective_policy_hash: str, -) -> dict[str, Any]: - """Build the default checker specification from an effective project policy.""" - rules = [ - _rule( - "validate_submission_packet", - ["required_packet_fields"], - {"fields": effective_policy.get("required_packet_fields", [])}, - ), - _rule( - "enforce_storage_scheme", - ["allowed_storage_schemes"], - {"schemes": effective_policy.get("allowed_storage_schemes", [])}, - ), - ] - if effective_policy.get("manifest_required"): - rules.append(_rule("require_manifest_field", ["manifest_required"], {})) - if effective_policy.get("artifact_hash_required"): - rules.append( - _rule( - "verify_hash", - ["artifact_hash_required", "artifact_hash_algorithm"], - {"algorithm": effective_policy.get("artifact_hash_algorithm")}, - ) - ) - required_artifacts = [ - artifact - for artifact in effective_policy.get("required_artifacts", []) - if artifact.get("required", True) - ] - if required_artifacts: - artifact_paths = _project_unique_paths( - required_artifacts, - projector=_required_artifact_path, - field="required_artifacts", - ) - rules.append( - _rule( - "require_file", - ["required_artifacts"], - { - "artifact_paths": artifact_paths - }, - ) - ) - required_evidence = [ - evidence - for evidence in effective_policy.get("required_evidence", []) - if evidence.get("required", True) - ] - if required_evidence: - evidence_paths = _project_unique_paths( - required_evidence, - projector=lambda item: required_evidence_path(item.get("key")), - field="required_evidence", - ) - rules.append( - _rule( - "require_minimum_evidence", - ["required_evidence"], - { - "evidence_paths": evidence_paths - }, - ) - ) - forbidden_artifacts = effective_policy.get("forbidden_artifacts", []) - if forbidden_artifacts: - rules.append( - _rule( - "forbid_artifact", - ["forbidden_artifacts"], - {"patterns": [artifact["pattern"] for artifact in forbidden_artifacts]}, - ) - ) - attestation_terms = effective_policy.get("attestation_terms", []) - if attestation_terms: - rules.append( - _rule( - "require_attestation", - ["attestation_terms"], - {"terms": attestation_terms}, - ) - ) - if effective_policy.get("maximum_file_size_bytes") is not None: - rules.append( - _rule( - "limit_file_size", - ["maximum_file_size_bytes"], - {"maximum_file_size_bytes": effective_policy["maximum_file_size_bytes"]}, - ) - ) - if effective_policy.get("maximum_package_size_bytes") is not None: - rules.append( - _rule( - "limit_package_size", - ["maximum_package_size_bytes"], - {"maximum_package_size_bytes": effective_policy["maximum_package_size_bytes"]}, - ) - ) - if effective_policy.get("maximum_archive_size_bytes") is not None: - rules.append(_rule( - "limit_archive_size", ["maximum_archive_size_bytes"], - {"maximum_archive_size_bytes": effective_policy["maximum_archive_size_bytes"]}, - )) - if effective_policy.get("maximum_archive_entries") is not None: - rules.append(_rule( - "limit_archive_entries", ["maximum_archive_entries"], - {"maximum_archive_entries": effective_policy["maximum_archive_entries"]}, - )) - packaging = effective_policy.get("packaging", {}) - if packaging.get("package_required") or packaging.get("allowed_package_formats"): - rules.append(_rule("require_packaging", ["packaging"], packaging)) - rules.append( - _rule( - "warn_low_quality_generated_artifact", - ["workstream_default_policy"], - {}, - severity=WARNING_SEVERITY, - ) - ) - return { - "schema_version": PRE_SUBMIT_SPEC_SCHEMA_VERSION, - "effective_policy_hash": effective_policy_hash, - "rules": sorted(rules, key=lambda rule: rule["primitive"]), - } - - -def compile_effective_project_submission_artifact_policy( - effective_policy: dict[str, Any], - effective_policy_hash: str, - *, - compiler_version: str = PRE_SUBMIT_COMPILER_VERSION, -) -> CompiledPreSubmitCheckerPolicy: - """Compile the default project pre-submit checker bundle.""" - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - return compile_project_pre_submit_checker_spec( - effective_policy, - effective_policy_hash, - spec, - compiler_version=compiler_version, - ) - - -def compile_project_pre_submit_checker_spec( - effective_policy: dict[str, Any], - effective_policy_hash: str, - spec: dict[str, Any], - *, - compiler_version: str = PRE_SUBMIT_COMPILER_VERSION, -) -> CompiledPreSubmitCheckerPolicy: - """Validate and compile a project pre-submit checker specification.""" - _validate_spec_shape(spec, effective_policy_hash) - rules = [_canonical_rule(rule) for rule in spec["rules"]] - _validate_rule_coverage(effective_policy, rules) - bundle = { - "schema_version": PRE_SUBMIT_BUNDLE_SCHEMA_VERSION, - "compiler_version": compiler_version, - "primitives_version": PRIMITIVES_VERSION, - "effective_policy_hash": effective_policy_hash, - "rules": rules, - } - checker_names = _checker_names_for_rules(rules) - return CompiledPreSubmitCheckerPolicy( - compiler_version=compiler_version, - compiled_bundle=bundle, - compiled_bundle_hash=canonical_json_hash(bundle), - checker_names=checker_names, - checker_configs={ - rule["primitive"]: rule["config"] - for rule in rules - }, - ) - - -def validate_compiled_pre_submit_checker_bundle( - effective_policy: dict[str, Any], - effective_policy_hash: str, - compiled_bundle: dict[str, Any], - *, - compiler_version: str | None = None, -) -> list[str]: - """Validate a persisted compiled bundle against its effective policy. - - Args: - effective_policy: Effective project submission artifact policy body. - effective_policy_hash: Canonical hash locked by the task/submission. - compiled_bundle: Persisted compiled checker bundle to validate. - compiler_version: Optional compiler version expected by the row. - - Returns: - Stable checker-name projection derived from the compiled rules. - - Raises: - PreSubmitCheckerCompilerError: If the compiled bundle cannot fully - enforce the effective policy. - """ - if compiled_bundle.get("schema_version") != PRE_SUBMIT_BUNDLE_SCHEMA_VERSION: - raise PreSubmitCheckerCompilerError("compiled checker bundle schema version is invalid") - if compiler_version is not None and compiled_bundle.get("compiler_version") != compiler_version: - raise PreSubmitCheckerCompilerError("compiled checker bundle compiler version mismatch") - if compiled_bundle.get("primitives_version") != PRIMITIVES_VERSION: - raise PreSubmitCheckerCompilerError("compiled checker bundle primitives version is invalid") - if compiled_bundle.get("effective_policy_hash") != effective_policy_hash: - raise PreSubmitCheckerCompilerError( - "compiled checker bundle effective project submission artifact policy hash mismatch" - ) - rules = compiled_bundle.get("rules") - if not isinstance(rules, list) or not rules: - raise PreSubmitCheckerCompilerError("compiled checker bundle requires rules") - canonical_rules = [_canonical_rule(rule) for rule in rules] - if canonical_rules != rules: - raise PreSubmitCheckerCompilerError("compiled checker bundle rules are not canonical") - _validate_rule_coverage(effective_policy, canonical_rules) - checker_names = _checker_names_for_rules(canonical_rules) - return checker_names - - -def _rule( - primitive: str, - policy_fields: list[str], - config: dict[str, Any], - *, - severity: str = BLOCKING_SEVERITY, -) -> dict[str, Any]: - """Build one canonical checker-spec rule.""" - return { - "primitive": primitive, - "severity": severity, - "policy_fields": sorted(policy_fields), - "config": _canonical_value(config), - } - - -def _validate_spec_shape(spec: dict[str, Any], effective_policy_hash: str) -> None: - """Validate the top-level checker-spec envelope.""" - if spec.get("schema_version") != PRE_SUBMIT_SPEC_SCHEMA_VERSION: - raise PreSubmitCheckerCompilerError("checker spec schema version is invalid") - if spec.get("effective_policy_hash") != effective_policy_hash: - raise PreSubmitCheckerCompilerError( - "checker spec effective project submission artifact policy hash mismatch" - ) - rules = spec.get("rules") - if not isinstance(rules, list) or not rules: - raise PreSubmitCheckerCompilerError("checker spec requires rules") - - -def _canonical_rule(rule: dict[str, Any]) -> dict[str, Any]: - """Validate and normalize one checker-spec rule.""" - primitive = rule.get("primitive") - if not isinstance(primitive, str): - raise PreSubmitCheckerCompilerError("checker spec contains unknown primitive") - try: - definition = _compiler_catalogue().primitive_definition(primitive) - except PreSubmissionCatalogueError as exc: - raise PreSubmitCheckerCompilerError("checker spec contains unknown primitive") from exc - severity = rule.get("severity") - if severity not in {BLOCKING_SEVERITY, WARNING_SEVERITY}: - raise PreSubmitCheckerCompilerError("checker spec contains invalid severity") - expected_severity = ( - BLOCKING_SEVERITY if definition.classification.mandatory else WARNING_SEVERITY - ) - if severity != expected_severity: - message = ( - "checker spec weakens severity" - if definition.classification.mandatory - else "checker spec escalates warning-only rule" - ) - raise PreSubmitCheckerCompilerError(message) - policy_fields = rule.get("policy_fields") - if not isinstance(policy_fields, list) or not policy_fields: - raise PreSubmitCheckerCompilerError("checker spec rule lacks policy trace fields") - if not all(isinstance(field, str) and field for field in policy_fields): - raise PreSubmitCheckerCompilerError("checker spec rule contains invalid policy trace field") - actual_policy_fields = set(policy_fields) - expected_policy_fields = set(definition.policy_fields) - if actual_policy_fields != expected_policy_fields: - raise PreSubmitCheckerCompilerError( - f"checker spec rule has untraceable policy fields for {primitive}" - ) - config = rule.get("config", {}) - if not isinstance(config, dict): - raise PreSubmitCheckerCompilerError("checker spec rule config must be an object") - return { - "primitive": primitive, - "severity": severity, - "policy_fields": sorted(set(policy_fields)), - "config": _canonical_value(config), - } - - -def _validate_rule_coverage(effective_policy: dict[str, Any], rules: list[dict[str, Any]]) -> None: - """Reject checker specs that do not fully enforce the effective policy.""" - primitives = [rule["primitive"] for rule in rules] - if len(primitives) != len(set(primitives)): - raise PreSubmitCheckerCompilerError("checker spec contains duplicate primitive rules") - expected_primitives = _expected_primitives(effective_policy) - extra_primitives = sorted(set(primitives).difference(expected_primitives)) - if extra_primitives: - raise PreSubmitCheckerCompilerError( - f"checker spec contains untraceable primitive rules: {', '.join(extra_primitives)}" - ) - omitted_primitives = sorted(expected_primitives.difference(primitives)) - if omitted_primitives: - raise PreSubmitCheckerCompilerError( - f"checker spec omits required primitive rules: {', '.join(omitted_primitives)}" - ) - by_primitive = {rule["primitive"]: rule for rule in rules} - for field, primitive in ( - ("maximum_archive_entries", "limit_archive_entries"), - ("maximum_archive_size_bytes", "limit_archive_size"), - ): - maximum = effective_policy.get(field) - if maximum is not None: - _require_blocking_rule(by_primitive, primitive) - config = by_primitive[primitive]["config"] - if ( - type(maximum) is not int or maximum <= 0 - or type(config.get(field)) is not int - or config != {field: maximum} - ): - raise PreSubmitCheckerCompilerError(f"checker spec weakens {field}") - _require_warning_rule(by_primitive, "warn_low_quality_generated_artifact") - _require_blocking_rule(by_primitive, "validate_submission_packet") - _require_config_values( - by_primitive["validate_submission_packet"], - "fields", - effective_policy.get("required_packet_fields", []), - "required packet fields", - ) - if effective_policy.get("manifest_required"): - _require_blocking_rule(by_primitive, "require_manifest_field") - if effective_policy.get("artifact_hash_required"): - _require_blocking_rule(by_primitive, "verify_hash") - if by_primitive["verify_hash"]["config"].get("algorithm") != "sha256": - raise PreSubmitCheckerCompilerError("checker spec weakens artifact hash algorithm") - _require_blocking_rule(by_primitive, "enforce_storage_scheme") - _require_config_values( - by_primitive["enforce_storage_scheme"], - "schemes", - effective_policy.get("allowed_storage_schemes", []), - "allowed storage schemes", - ) - - required_artifact_paths = _project_unique_paths( - [ - artifact - for artifact in effective_policy.get("required_artifacts", []) - if artifact.get("required", True) - ], - projector=_required_artifact_path, - field="required_artifacts", - ) - if required_artifact_paths: - _require_blocking_rule(by_primitive, "require_file") - _require_config_values( - by_primitive["require_file"], - "artifact_paths", - required_artifact_paths, - "required artifacts", - ) - required_evidence_paths = _project_unique_paths( - [ - evidence - for evidence in effective_policy.get("required_evidence", []) - if evidence.get("required", True) - ], - projector=lambda item: required_evidence_path(item.get("key")), - field="required_evidence", - ) - if required_evidence_paths: - _require_blocking_rule(by_primitive, "require_minimum_evidence") - _require_config_values( - by_primitive["require_minimum_evidence"], - "evidence_paths", - required_evidence_paths, - "required evidence", - ) - forbidden_patterns = [ - artifact["pattern"] - for artifact in effective_policy.get("forbidden_artifacts", []) - ] - if forbidden_patterns: - _require_blocking_rule(by_primitive, "forbid_artifact") - _require_config_values( - by_primitive["forbid_artifact"], - "patterns", - forbidden_patterns, - "forbidden artifacts", - ) - attestation_terms = effective_policy.get("attestation_terms", []) - if attestation_terms: - _require_blocking_rule(by_primitive, "require_attestation") - _require_config_values( - by_primitive["require_attestation"], - "terms", - attestation_terms, - "attestation terms", - ) - if effective_policy.get("maximum_file_size_bytes") is not None: - _require_blocking_rule(by_primitive, "limit_file_size") - if ( - by_primitive["limit_file_size"]["config"].get("maximum_file_size_bytes") - != effective_policy["maximum_file_size_bytes"] - ): - raise PreSubmitCheckerCompilerError("checker spec weakens file size limit") - if effective_policy.get("maximum_package_size_bytes") is not None: - _require_blocking_rule(by_primitive, "limit_package_size") - if ( - by_primitive["limit_package_size"]["config"].get("maximum_package_size_bytes") - != effective_policy["maximum_package_size_bytes"] - ): - raise PreSubmitCheckerCompilerError("checker spec weakens package size limit") - packaging = effective_policy.get("packaging", {}) - if packaging.get("package_required") or packaging.get("allowed_package_formats"): - _require_blocking_rule(by_primitive, "require_packaging") - if by_primitive["require_packaging"]["config"] != _canonical_value(packaging): - raise PreSubmitCheckerCompilerError("checker spec weakens packaging requirements") - - default_policy = effective_policy.get("workstream_default_policy") - if not isinstance(default_policy, dict): - raise PreSubmitCheckerCompilerError("effective policy lacks Workstream defaults") - _require_config_contains( - by_primitive["validate_submission_packet"], - "fields", - default_policy.get("required_packet_fields", []), - "Workstream default packet fields", - ) - _require_config_contains( - by_primitive["forbid_artifact"], - "patterns", - [artifact["pattern"] for artifact in default_policy.get("forbidden_artifacts", [])], - "Workstream default forbidden artifacts", - ) - _require_config_contains( - by_primitive["require_attestation"], - "terms", - default_policy.get("attestation_terms", []), - "Workstream default attestation terms", - ) - - -def _require_blocking_rule(by_primitive: dict[str, dict[str, Any]], primitive: str) -> None: - """Require one blocking primitive rule.""" - rule = by_primitive.get(primitive) - if rule is None: - raise PreSubmitCheckerCompilerError(f"checker spec omits {primitive}") - if rule["severity"] != BLOCKING_SEVERITY: - raise PreSubmitCheckerCompilerError(f"checker spec weakens severity for {primitive}") - - -def _require_warning_rule(by_primitive: dict[str, dict[str, Any]], primitive: str) -> None: - """Require one warning-only primitive rule.""" - rule = by_primitive.get(primitive) - if rule is None: - raise PreSubmitCheckerCompilerError(f"checker spec omits {primitive}") - if rule["severity"] != WARNING_SEVERITY: - raise PreSubmitCheckerCompilerError(f"checker spec escalates warning-only rule for {primitive}") - if rule["config"] != {}: - raise PreSubmitCheckerCompilerError(f"checker spec adds config for warning-only rule {primitive}") - - -def _require_config_values( - rule: dict[str, Any], - config_key: str, - expected_values: list[Any], - label: str, -) -> None: - """Require a rule config list to exactly match expected values.""" - actual_values = set(rule["config"].get(config_key, [])) - expected_value_set = set(expected_values) - missing_values = sorted(expected_value_set.difference(actual_values)) - if missing_values: - raise PreSubmitCheckerCompilerError( - f"checker spec omits {label}: {', '.join(str(value) for value in missing_values)}" - ) - extra_values = sorted(actual_values.difference(expected_value_set)) - if extra_values: - raise PreSubmitCheckerCompilerError( - f"checker spec adds untraceable {label}: {', '.join(str(value) for value in extra_values)}" - ) - - -def _require_config_contains( - rule: dict[str, Any], - config_key: str, - expected_values: list[Any], - label: str, -) -> None: - """Require a rule config list to include platform-default values.""" - actual_values = set(rule["config"].get(config_key, [])) - missing_values = sorted(set(expected_values).difference(actual_values)) - if missing_values: - raise PreSubmitCheckerCompilerError( - f"checker spec omits {label}: {', '.join(str(value) for value in missing_values)}" - ) - - -def _expected_primitives(effective_policy: dict[str, Any]) -> set[str]: - """Return primitive rules required to enforce one effective policy.""" - expected = { - "enforce_storage_scheme", - "validate_submission_packet", - "warn_low_quality_generated_artifact", - } - if effective_policy.get("manifest_required"): - expected.add("require_manifest_field") - if effective_policy.get("artifact_hash_required"): - expected.add("verify_hash") - required_artifacts = _policy_object_list(effective_policy, "required_artifacts") - if [artifact["key"] for artifact in required_artifacts if artifact.get("required", True)]: - expected.add("require_file") - required_evidence = _policy_object_list(effective_policy, "required_evidence") - if [evidence["key"] for evidence in required_evidence if evidence.get("required", True)]: - expected.add("require_minimum_evidence") - if effective_policy.get("forbidden_artifacts", []): - expected.add("forbid_artifact") - if effective_policy.get("attestation_terms", []): - expected.add("require_attestation") - if effective_policy.get("maximum_file_size_bytes") is not None: - expected.add("limit_file_size") - if effective_policy.get("maximum_package_size_bytes") is not None: - expected.add("limit_package_size") - if effective_policy.get("maximum_archive_entries") is not None: - expected.add("limit_archive_entries") - if effective_policy.get("maximum_archive_size_bytes") is not None: - expected.add("limit_archive_size") - packaging = effective_policy.get("packaging", {}) - if not isinstance(packaging, dict): - raise PreSubmitCheckerCompilerError( - "effective project submission artifact policy packaging must be an object" - ) - if packaging.get("package_required") or packaging.get("allowed_package_formats"): - expected.add("require_packaging") - return expected - - -def _policy_object_list(effective_policy: dict[str, Any], field: str) -> list[dict[str, Any]]: - """Return a policy list that must contain object entries with keys.""" - values = effective_policy.get(field, []) - if not isinstance(values, list): - raise PreSubmitCheckerCompilerError( - f"effective project submission artifact policy {field} must be a list" - ) - keys: set[str] = set() - for value in values: - if not isinstance(value, dict) or not isinstance(value.get("key"), str): - raise PreSubmitCheckerCompilerError( - f"effective project submission artifact policy {field} entries are invalid" - ) - key = value["key"] - if not key or key in keys: - raise PreSubmitCheckerCompilerError( - f"effective project submission artifact policy {field} keys are invalid" - ) - keys.add(key) - return values - - -def _required_artifact_path(item: dict[str, Any]) -> str: - """Require the explicit server-approved path attached to an artifact key.""" - value = item.get("path") - if not isinstance(value, str) or not is_canonical_relative_path(value): - raise PreSubmitCheckerCompilerError( - "effective project submission artifact policy path is invalid" - ) - return value - - -def _project_unique_paths( - items: list[dict[str, Any]], - *, - projector: Callable[[dict[str, Any]], str], - field: str, -) -> list[str]: - """Project policy identities into unique canonical server-owned paths.""" - try: - paths = [projector(item) for item in items] - except ValueError as exc: - raise PreSubmitCheckerCompilerError(str(exc)) from exc - if len(paths) != len(set(paths)): - raise PreSubmitCheckerCompilerError( - f"effective project submission artifact policy {field} paths are ambiguous" - ) - return paths - - -def _checker_names_for_rules(rules: list[dict[str, Any]]) -> list[str]: - """Build stable checker-name projections from compiled primitive rules.""" - catalogue = _compiler_catalogue() - names: list[str] = [] - for rule in rules: - try: - name = catalogue.primitive_definition(rule["primitive"]).public_name - except PreSubmissionCatalogueError as exc: - raise PreSubmitCheckerCompilerError( - "compiled checker bundle references unknown catalogue definitions" - ) from exc - if name not in names: - names.append(name) - return names - - -def _canonical_value(value: Any) -> Any: - """Recursively canonicalize JSON-like values.""" - if isinstance(value, dict): - return {key: _canonical_value(value[key]) for key in sorted(value)} - if isinstance(value, list): - return [_canonical_value(item) for item in value] - return value diff --git a/backend/app/modules/checkers/default_checker.py b/backend/app/modules/checkers/default_checker.py new file mode 100644 index 000000000..848effd22 --- /dev/null +++ b/backend/app/modules/checkers/default_checker.py @@ -0,0 +1,285 @@ +"""Unselected implementation of the one Workstream default checker.""" + +from __future__ import annotations + +import hashlib +import re +from typing import cast + +from app.core.hashing import canonical_json_hash +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + WORKSTREAM_DEFAULT_SECRET_SIGNATURES, + WorkstreamDefaultArchiveFailureCode, + WorkstreamDefaultCheck, + WorkstreamDefaultCheckId, + WorkstreamDefaultCheckerInput, + WorkstreamDefaultCheckerResult, + WorkstreamDefaultFailureCode, + WorkstreamDefaultWarning, +) +from app.modules.checkers.api.materialization import ( + SubmissionMaterialEntry, + SubmissionMaterialView, +) +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView + + +_CONTRIBUTOR_ARCHIVE_FAILURES = frozenset( + { + "submission_archive_malformed", + "submission_archive_limit_exceeded", + "submission_archive_unsafe_entry", + "submission_archive_collision", + "submission_archive_encrypted", + } +) +_SECRET_SIGNATURES = tuple( + re.compile(pattern.encode("ascii"), flags) + for _, pattern, flags in WORKSTREAM_DEFAULT_SECRET_SIGNATURES +) + + +class WorkstreamDefaultCheckerUnavailable(RuntimeError): + """Fail closed without attributing an infrastructure defect to the contributor.""" + + +def evaluate_rejected_archive( + failure: WorkstreamDefaultArchiveFailureCode, + *, + packet: SubmissionPacketView, +) -> WorkstreamDefaultCheckerResult: + """Return one bounded work finding for a contributor-correctable unsafe ZIP.""" + if failure not in _CONTRIBUTOR_ARCHIVE_FAILURES: + raise WorkstreamDefaultCheckerUnavailable(str(failure)) + return _result( + checks=( + _failed("archive_safe", cast(WorkstreamDefaultFailureCode, failure)), + _not_run("received_identity_exact"), + _not_run("high_confidence_secrets_absent"), + _not_run("submission_changed_for_current_rules"), + ), + warnings=_warnings(packet), + ) + + +def evaluate_verified_archive( + value: WorkstreamDefaultCheckerInput, + material: SubmissionMaterialView, +) -> WorkstreamDefaultCheckerResult: + """Evaluate all four defaults during one callback-scoped verified-byte lifetime.""" + try: + manifest_entries = _validated_entries(value.entries) + observed_entries = _validated_entries(material.entries) + if observed_entries != manifest_entries: + raise WorkstreamDefaultCheckerUnavailable( + "submission_material_identity_invalid" + ) + if canonical_json_hash(_manifest_body(manifest_entries)) != ( + value.semantic_manifest_sha256 + ): + raise WorkstreamDefaultCheckerUnavailable( + "submission_manifest_identity_invalid" + ) + secret_file_count = _verify_files_and_count_secrets(material, manifest_entries) + change_failure = _change_failure(value) + except WorkstreamDefaultCheckerUnavailable: + raise + except Exception as exc: + raise WorkstreamDefaultCheckerUnavailable( + "workstream_default_checker_input_unavailable" + ) from exc + checks = ( + _passed("archive_safe"), + _passed("received_identity_exact"), + ( + _failed( + "high_confidence_secrets_absent", + "high_confidence_secret_detected", + count=secret_file_count, + ) + if secret_file_count + else _passed("high_confidence_secrets_absent") + ), + ( + _failed("submission_changed_for_current_rules", change_failure) + if change_failure is not None + else _passed("submission_changed_for_current_rules") + ), + ) + return _result(checks=checks, warnings=_warnings(value.packet)) + + +def _validated_entries( + values: tuple[object, ...], +) -> tuple[SubmissionMaterialEntry, ...]: + """Detach and validate the exact canonical entry projection.""" + entries = tuple( + SubmissionMaterialEntry( + normalized_path=getattr(item, "normalized_path"), + entry_type=cast(str, getattr(item, "entry_type")), + byte_count=getattr(item, "byte_count"), + sha256=getattr(item, "sha256"), + executable=getattr(item, "executable"), + ) + for item in values + ) + if tuple(sorted(entries, key=lambda item: item.normalized_path)) != entries: + raise ValueError("default checker entries are not canonical") + if len({item.normalized_path for item in entries}) != len(entries): + raise ValueError("default checker entries are duplicated") + for item in entries: + if type(item.normalized_path) is not str or not item.normalized_path: + raise ValueError("default checker entry path is invalid") + if item.entry_type == "directory": + if item.byte_count != 0 or item.sha256 is not None or item.executable is not None: + raise ValueError("default checker directory entry is invalid") + continue + if ( + item.entry_type != "file" + or type(item.byte_count) is not int + or item.byte_count < 0 + or type(item.executable) is not bool + or not _is_sha256(item.sha256) + ): + raise ValueError("default checker file entry is invalid") + return entries + + +def _manifest_body(entries: tuple[SubmissionMaterialEntry, ...]) -> dict[str, object]: + """Recompute the canonical ART semantic-manifest body from detached facts.""" + body_entries: list[dict[str, object]] = [] + for entry in entries: + if entry.entry_type == "directory": + body_entries.append( + {"normalized_path": entry.normalized_path, "entry_type": "directory"} + ) + else: + body_entries.append( + { + "normalized_path": entry.normalized_path, + "entry_type": "file", + "sha256": entry.sha256, + "byte_count": entry.byte_count, + "executable": entry.executable, + } + ) + return { + "schema_version": "workstream.submission_bundle_manifest.v1", + "entries": body_entries, + } + + +def _change_failure( + value: WorkstreamDefaultCheckerInput, +) -> WorkstreamDefaultFailureCode | None: + """Apply unchanged-work refusal only when the canonical task rules are unchanged.""" + predecessor = value.predecessor + if predecessor is None: + if value.predecessor_exists or value.current_predecessor is not None: + raise WorkstreamDefaultCheckerUnavailable( + "submission_canonical_predecessor_unavailable" + ) + return None + if not value.predecessor_exists: + raise WorkstreamDefaultCheckerUnavailable( + "submission_canonical_predecessor_unavailable" + ) + if value.current_predecessor != predecessor: + raise WorkstreamDefaultCheckerUnavailable("submission_predecessor_stale") + if value.predecessor_rules_sha256 != value.current_rules_sha256: + return None + if value.archive_sha256 == predecessor.archive_sha256: + return "submission_archive_unchanged" + if value.semantic_manifest_sha256 == predecessor.semantic_manifest_sha256: + return "submission_manifest_unchanged" + return None + + +def _verify_files_and_count_secrets( + material: SubmissionMaterialView, + entries: tuple[SubmissionMaterialEntry, ...], +) -> int: + """Verify every file again and count matches without retaining matched bytes.""" + affected = 0 + for entry in entries: + if entry.entry_type != "file": + continue + content = material.read_file( + entry.normalized_path, maximum_bytes=entry.byte_count + ) + if ( + len(content) != entry.byte_count + or f"sha256:{hashlib.sha256(content).hexdigest()}" != entry.sha256 + ): + raise WorkstreamDefaultCheckerUnavailable( + "submission_material_identity_invalid" + ) + if any(pattern.search(content) is not None for pattern in _SECRET_SIGNATURES): + affected += 1 + return affected + + +def _is_sha256(value: object) -> bool: + return ( + type(value) is str + and len(value) == 71 + and value.startswith("sha256:") + and all(character in "0123456789abcdef" for character in value[7:]) + ) + + +def _warnings(packet: SubmissionPacketView) -> tuple[WorkstreamDefaultWarning, ...]: + """Return stable warning-only absence signals in canonical order.""" + values = [] + if not packet.summary.strip(): + values.append(WorkstreamDefaultWarning(code="submission_summary_missing")) + if not packet.contributor_attestation.strip(): + values.append(WorkstreamDefaultWarning(code="submission_attestation_missing")) + return tuple(values) + + +def _passed(check_id: WorkstreamDefaultCheckId) -> WorkstreamDefaultCheck: + return WorkstreamDefaultCheck( + check_id=check_id, status="passed", failure_code=None, finding_count=0 + ) + + +def _not_run(check_id: WorkstreamDefaultCheckId) -> WorkstreamDefaultCheck: + return WorkstreamDefaultCheck( + check_id=check_id, status="not_run", failure_code=None, finding_count=0 + ) + + +def _failed( + check_id: WorkstreamDefaultCheckId, + failure_code: WorkstreamDefaultFailureCode, + *, + count: int = 1, +) -> WorkstreamDefaultCheck: + return WorkstreamDefaultCheck( + check_id=check_id, + status="failed", + failure_code=failure_code, + finding_count=count, + ) + + +def _result( + *, + checks: tuple[WorkstreamDefaultCheck, ...], + warnings: tuple[WorkstreamDefaultWarning, ...], +) -> WorkstreamDefaultCheckerResult: + return WorkstreamDefaultCheckerResult( + behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + eligible=all(item.status == "passed" for item in checks), + checks=checks, + warnings=warnings, + ) + + +__all__ = ( + "WorkstreamDefaultCheckerUnavailable", + "evaluate_rejected_archive", + "evaluate_verified_archive", +) diff --git a/backend/app/modules/checkers/effective_plan.py b/backend/app/modules/checkers/effective_plan.py deleted file mode 100644 index 15864d1df..000000000 --- a/backend/app/modules/checkers/effective_plan.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Pure compiler for one locked effective pre-submission execution plan.""" - -from __future__ import annotations - -from typing import Any - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api import ( - EffectivePreSubmissionExecutionPlan, - EffectivePreSubmissionPlanEntry, - EffectivePreSubmissionPlanError, - EffectivePreSubmissionPlanLineage, - FrozenJsonObject, - PreSubmissionInfrastructureUnavailableError, -) -from app.modules.checkers.api.pre_submit import effective_plan_body, freeze_json -from app.modules.checkers.catalogue import ( - PreSubmissionCheckerCatalogue, - PreSubmissionCheckerDefinition, - PreSubmissionCheckerPhase, - PreSubmissionCheckerState, - pre_submission_phase_order, -) -from app.modules.checkers.compiler import ( - PRE_SUBMIT_BUNDLE_SCHEMA_VERSION, - PRE_SUBMIT_COMPILER_VERSION, - PRIMITIVES_VERSION, - PreSubmitCheckerCompilerError, - validate_compiled_pre_submit_checker_bundle, -) - - -def compile_effective_pre_submission_execution_plan( - *, - lineage: EffectivePreSubmissionPlanLineage, - effective_policy: dict[str, Any], - compiled_bundle: dict[str, Any], - catalogue: PreSubmissionCheckerCatalogue, -) -> EffectivePreSubmissionExecutionPlan: - """Compose platform definitions and locked project rules without execution.""" - if not catalogue.available: - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_infrastructure_unavailable" - ) - if canonical_json_hash(compiled_bundle) != lineage.pre_submit_policy_bundle_hash: - raise EffectivePreSubmissionPlanError("compiled checker bundle hash mismatch") - if ( - compiled_bundle.get("schema_version") != PRE_SUBMIT_BUNDLE_SCHEMA_VERSION - or compiled_bundle.get("compiler_version") != PRE_SUBMIT_COMPILER_VERSION - or compiled_bundle.get("primitives_version") != PRIMITIVES_VERSION - ): - raise EffectivePreSubmissionPlanError("compiled checker bundle envelope is invalid") - if compiled_bundle.get("effective_policy_hash") != lineage.effective_policy_hash: - raise EffectivePreSubmissionPlanError("compiled checker effective policy mismatch") - if canonical_json_hash(effective_policy) != lineage.effective_policy_hash: - raise EffectivePreSubmissionPlanError( - "locked effective project submission artifact policy hash mismatch" - ) - try: - validate_compiled_pre_submit_checker_bundle( - effective_policy, - lineage.effective_policy_hash, - compiled_bundle, - compiler_version=PRE_SUBMIT_COMPILER_VERSION, - ) - except PreSubmitCheckerCompilerError as exc: - raise EffectivePreSubmissionPlanError( - f"compiled checker bundle does not enforce the locked effective policy: {exc}" - ) from exc - rules = compiled_bundle.get("rules") - if not isinstance(rules, list) or not rules: - raise EffectivePreSubmissionPlanError("compiled checker bundle rules are invalid") - - entries = [ - _entry_from_definition(definition, configuration={}, rule_instance_id=None) - for definition in catalogue.entries - if definition.primitive is None - ] - seen_primitives: set[str] = set() - catalogue_manifest_sha256 = catalogue.manifest_sha256 - for rule in rules: - if not isinstance(rule, dict): - raise EffectivePreSubmissionPlanError("compiled checker rule is invalid") - primitive = rule.get("primitive") - if not isinstance(primitive, str) or primitive in seen_primitives: - raise EffectivePreSubmissionPlanError("compiled checker primitive is invalid") - seen_primitives.add(primitive) - try: - definition = catalogue.primitive_definition(primitive) - except ValueError as exc: - raise EffectivePreSubmissionPlanError("compiled checker primitive is unknown") from exc - if definition.state is PreSubmissionCheckerState.DISABLED: - if definition.classification.mandatory: - raise PreSubmissionInfrastructureUnavailableError( - "pre_submission_infrastructure_unavailable" - ) - configuration = rule.get("config") - if not isinstance(configuration, dict): - raise EffectivePreSubmissionPlanError("compiled checker configuration is invalid") - if rule.get("policy_fields") != list(definition.policy_fields): - raise EffectivePreSubmissionPlanError("compiled checker policy trace is invalid") - expected_severity = "warning" if not definition.classification.mandatory else "blocking" - if rule.get("severity") != expected_severity: - raise EffectivePreSubmissionPlanError("compiled checker severity is invalid") - rule_instance_id = canonical_json_hash( - { - "domain": "workstream.pre_submission_rule_instance.v1", - "catalogue_id": catalogue.catalogue_id, - "catalogue_version": catalogue.version, - "catalogue_manifest_sha256": catalogue_manifest_sha256, - "definition_id": definition.stable_id, - "definition_version": definition.version, - "effective_policy_id": str(lineage.effective_policy_id), - "effective_policy_hash": lineage.effective_policy_hash, - "pre_submit_policy_id": str(lineage.pre_submit_policy_id), - "configuration": configuration, - } - ) - entries.append( - _entry_from_definition( - definition, - configuration=configuration, - rule_instance_id=rule_instance_id, - ) - ) - ordered_entries = tuple( - sorted( - entries, key=lambda entry: (_phase_order(entry.phase), entry.order, entry.definition_id) - ) - ) - body = effective_plan_body( - lineage, - catalogue.catalogue_id, - catalogue.version, - catalogue.schema_version, - catalogue_manifest_sha256, - ordered_entries, - ) - return EffectivePreSubmissionExecutionPlan( - lineage=lineage, - catalogue_id=catalogue.catalogue_id, - catalogue_version=catalogue.version, - catalogue_schema_version=catalogue.schema_version, - catalogue_manifest_sha256=catalogue_manifest_sha256, - entries=ordered_entries, - plan_sha256=canonical_json_hash(body), - ) - - -def _entry_from_definition( - definition: PreSubmissionCheckerDefinition, - *, - configuration: dict[str, Any], - rule_instance_id: str | None, -) -> EffectivePreSubmissionPlanEntry: - configuration_sha256 = canonical_json_hash(configuration) - frozen_configuration = FrozenJsonObject( - tuple((key, freeze_json(value)) for key, value in sorted(configuration.items())) - ) - return EffectivePreSubmissionPlanEntry( - definition_id=definition.stable_id, - definition_version=definition.version, - public_name=definition.public_name, - phase=definition.phase.value, - order=definition.order, - dependencies=definition.dependencies, - classification=definition.classification.value, - checker_definition_state=definition.state.value, - disabled_behavior=definition.disabled_behavior.value, - dispatch_kind=definition.dispatch_kind.value, - dispatch_capability=definition.dispatch_capability, - typed_inputs=definition.typed_inputs, - result_schema=definition.result_schema, - failure_code=definition.failure_code, - resource_budget=definition.resource_budget, - policy_trace_source=definition.policy_trace_source, - rule_instance_id=rule_instance_id, - configuration=frozen_configuration, - configuration_sha256=configuration_sha256, - ) - - -def _phase_order(phase: str) -> int: - return pre_submission_phase_order(PreSubmissionCheckerPhase(phase)) diff --git a/backend/app/modules/checkers/execution.py b/backend/app/modules/checkers/execution.py index da9602541..354f0cc7f 100644 --- a/backend/app/modules/checkers/execution.py +++ b/backend/app/modules/checkers/execution.py @@ -2,6 +2,7 @@ import asyncio import json +import math from dataclasses import asdict from datetime import timedelta from uuid import UUID @@ -26,34 +27,40 @@ PreparedFinalization, VerifiedMaterialFacts, ) -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, - PostSubmitCurrentResultReference, +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, + PostSubmissionExecutionResult, + validate_execution_request, ) -from app.modules.checkers.api.post_submit_catalogue import canonical_post_submit_bytes +from app.modules.checkers.api.external import PostSubmitExternalCheckerIdentity +from app.modules.checkers.api.external_policy import ( + SELECTED_POST_FINALIZATION_MARGIN_MS, + selected_post_submit_execution_budget_ms, +) +from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmitCurrentResultReference, + SelectedPostSubmissionEvaluationRequest, + SelectedPostSubmitInfrastructureUnavailable, + make_selected_post_submit_result, +) +from app.modules.checkers.selected_post_submit_execution import ( + SelectedPostSubmitProcessor, +) +from app.interfaces.external_checker_execution import ExternalCheckerExecutionAdapter +from app.modules.checkers.api.canonical_values import canonical_checker_bytes from app.modules.checkers.execution_repository import ( ExecutionRepository, reservation, stored_result, ) from app.modules.checkers.execution_results import classify_result -from app.modules.checkers.post_submit_contracts import make_post_submit_result -from app.modules.checkers.post_submit_implementations import ( - bounded_structural_result, - detached_checker_context, -) -from app.modules.checkers.runner import UnknownChecker, CheckerRegistry from app.modules.checkers.api.materialization import ( - PostSubmissionMaterializationPort, PostSubmissionMaterializationFailure, + PostSubmissionMaterializationPort, + PostSubmissionMaterializationFailure, ) from app.modules.outbox.api import OutboxAppendPort from app.modules.outbox.api import OutboxAppendInput -LEASE_SECONDS = 300 -EXECUTION_TIMEOUT_SECONDS = 240 - - def _lease(run): """Recover the exact persisted lease, including its database expiry.""" return ExecutionLease( @@ -71,62 +78,61 @@ def _evidence(value, expected_type, facts): return value.evidence_id -class _StructuralConsumer: - """Run registered structural checks while ART owns the verified private view.""" +class _SelectedExternalConsumer: + """Run the selected POST policy through the existing ART callback lifetime.""" - def __init__(self, registry, lease): - """Bind the installed registry and reserved attempt identities.""" - self._registry, self._lease = registry, lease + def __init__( + self, + execution: ExternalCheckerExecutionAdapter, + lease: ExecutionLease, + ) -> None: + self._execution = execution + self._lease = lease async def evaluate(self, request, material): - # ART retains the verified private view for this callback. These registered - # rules inspect structural facts whose exact archive manifest ART verified. - """Validate installed definitions and convert bounded outcomes to closed results.""" - del material - identity = dict( + request = SelectedPostSubmissionEvaluationRequest.model_validate(request) + reservation = self._lease.reservation + identity = PostSubmitExternalCheckerIdentity( + 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_request_digest=request.request_sha256, + evaluation_generation=request.evaluation_generation, + attempt_id=reservation.attempt_id, + result_id=reservation.result_id, + lease_id=self._lease.lease_id, + lease_generation=self._lease.lease_generation, + lease_expires_at=self._lease.expires_at, + ) + values = dict( request_id=request.evaluation_request_id, request_digest=request.request_sha256, - attempt_id=self._lease.reservation.attempt_id, - result_id=self._lease.reservation.result_id, + attempt_id=reservation.attempt_id, evaluation_generation=request.evaluation_generation, + result_id=reservation.result_id, ) try: - for entry in request.policy.entries: - if self._registry.resolve( - entry.checker_id - ).definition != request.catalogue.definition( - entry.checker_id, entry.definition_version - ): - raise ValueError("post-submit installed catalogue mismatch") - limits = request.catalogue.definitions[0].resources - async with asyncio.timeout(limits.phase_deadline_ms / 1000): - outcomes = await self._registry.run( - detached_checker_context(request), request.policy.entries - ) - members = tuple( - bounded_structural_result( - request.catalogue.definition(entry.checker_id, entry.definition_version), - outcome, - ) - for entry, outcome in zip(request.policy.entries, outcomes, strict=True) + execution = await SelectedPostSubmitProcessor( + policy=request.policy, + identity=identity, + execution=self._execution, + ).evaluate(request, material) + return make_selected_post_submit_result( + **values, + outcome="completed", + execution=execution, + infrastructure_failure_code=None, ) - result = make_post_submit_result( - **identity, outcome="completed", member_results=members + except SelectedPostSubmitInfrastructureUnavailable as exc: + return make_selected_post_submit_result( + **values, + outcome="infrastructure_failed", + execution=None, + infrastructure_failure_code=exc.code, ) - result.validate_request(request) - return result - except TimeoutError: - code = "deadline_exceeded" - except UnknownChecker: - code = "implementation_unavailable" - except ValueError: - code = "invalid_output" - return make_post_submit_result( - **identity, - outcome="infrastructure_failed", - member_results=(), - infrastructure_failure_code=code, - ) class PostSubmissionExecutor: @@ -139,13 +145,23 @@ def __init__( execute_authority: Callable[[AsyncSession], ExecutionAuthorityPort], finalize_authority: Callable[[AsyncSession], FinalizationAuthorityPort], materialization: PostSubmissionMaterializationPort, - registry: CheckerRegistry, + selected_execution: ExternalCheckerExecutionAdapter | None = None, outbox: Callable[[AsyncSession], OutboxAppendPort], ): """Receive explicit session, authority, materialization and publication ports.""" self._sessions = sessions self._execute_authority, self._finalize_authority = execute_authority, finalize_authority - self._materialization, self._registry, self._outbox = materialization, registry, outbox + self._materialization, self._outbox = materialization, outbox + self._selected_execution = selected_execution + + @staticmethod + def _execution_budget_seconds(request: PostSubmissionExecutionRequest) -> int: + return math.ceil(selected_post_submit_execution_budget_ms(request.policy) / 1000) + + def _consumer(self, request, lease): + if self._selected_execution is None: + raise CheckerExecutionUnavailable("checker_external_execution_unavailable") + return _SelectedExternalConsumer(self._selected_execution, lease) async def _claim(self, request): """Authorize replay or acquire the next database-timed lease atomically.""" @@ -179,11 +195,15 @@ async def _claim(self, request): now = await repo.now() if run.status == "running" and run.worker_lease_expires_at > now: raise CheckerExecutionUnavailable("checker_execution_lease_unavailable") + execution_seconds = self._execution_budget_seconds(request) + lease_seconds = execution_seconds + math.ceil( + SELECTED_POST_FINALIZATION_MARGIN_MS / 1000 + ) lease = ExecutionLease( reservation=reservation(run), lease_id=new_record_id(), lease_generation=run.worker_lease_generation + 1, - expires_at=now + timedelta(seconds=LEASE_SECONDS), + expires_at=now + timedelta(seconds=lease_seconds), ) facts = ExecuteFacts(request=request, lease=lease) evidence_id = _evidence(await prepared.consume(facts), ExecuteEvidence, facts) @@ -197,19 +217,20 @@ async def _claim(self, request): return lease, None async def evaluate_post_submission( - self, request: PostSubmissionEvaluationRequest - ) -> PostSubmissionEvaluationResult: + self, request: PostSubmissionExecutionRequest + ) -> PostSubmissionExecutionResult: """Evaluate outside custody transactions, then separately authorize finalization.""" - request = PostSubmissionEvaluationRequest.model_validate(request) + request = validate_execution_request(request) lease, replay = await self._claim(request) if replay is not None: return await self.finalize(replay) material = None input_materialization_evidence_id = None try: - async with asyncio.timeout(EXECUTION_TIMEOUT_SECONDS): + async with asyncio.timeout(self._execution_budget_seconds(request)): materialized = await self._materialization.materialize( - ExecuteFacts(request=request, lease=lease), _StructuralConsumer(self._registry, lease) + ExecuteFacts(request=request, lease=lease), + self._consumer(request, lease), ) material = VerifiedMaterialFacts( **{ @@ -221,18 +242,23 @@ async def evaluate_post_submission( input_materialization_evidence_id = materialized.input_materialization_evidence_id result = materialized.evaluation except (TimeoutError, PostSubmissionMaterializationFailure) as error: - result = make_post_submit_result( + values = dict( request_id=request.evaluation_request_id, request_digest=request.request_sha256, attempt_id=lease.reservation.attempt_id, result_id=lease.reservation.result_id, evaluation_generation=request.evaluation_generation, outcome="infrastructure_failed", - member_results=(), infrastructure_failure_code=( - "deadline_exceeded" if isinstance(error, TimeoutError) else "material_unavailable" + "deadline_exceeded" + if isinstance(error, TimeoutError) + else "material_unavailable" ), ) + result = make_selected_post_submit_result( + **values, + execution=None, + ) return await self.finalize( FinalizeFacts( request=request, @@ -244,7 +270,7 @@ async def evaluate_post_submission( ) ) - async def finalize(self, facts: FinalizeFacts) -> PostSubmissionEvaluationResult: + async def finalize(self, facts: FinalizeFacts) -> PostSubmissionExecutionResult: """Fresh phase-specific PREP and one atomic terminal/member/outbox commit.""" facts = FinalizeFacts.model_validate(facts) request, result = facts.request, facts.result @@ -283,7 +309,8 @@ async def finalize(self, facts: FinalizeFacts) -> PostSubmissionEvaluationResult if run.execute_evidence_id is None: raise CheckerExecutionUnavailable("checker_execution_evidence_unavailable") authority_facts = FinalizeAuthorityFacts( - **facts.model_dump(), execute_evidence_id=UUID(run.execute_evidence_id), + **facts.model_dump(), + execute_evidence_id=UUID(run.execute_evidence_id), ) if run.status in {"completed", "infrastructure_failed"}: if ( @@ -302,13 +329,15 @@ async def finalize(self, facts: FinalizeFacts) -> PostSubmissionEvaluationResult await prepared.validate_replay(authority_facts, UUID(run.finalize_evidence_id)) return stored_result(run) run = await repo.require_lease(request, facts.lease) - evidence_id = _evidence(await prepared.consume(authority_facts), FinalizeEvidence, authority_facts) + evidence_id = _evidence( + await prepared.consume(authority_facts), FinalizeEvidence, authority_facts + ) completed_at = await repo.now() await repo.write_members(run, result) if result.outcome == "completed": - reference = PostSubmitCurrentResultReference( + reference = SelectedPostSubmitCurrentResultReference( **result.model_dump( - include=set(PostSubmitCurrentResultReference.model_fields) + include=set(SelectedPostSubmitCurrentResultReference.model_fields) - {"schema_version"} ) ) @@ -336,7 +365,7 @@ async def finalize(self, facts: FinalizeFacts) -> PostSubmissionEvaluationResult ) ) run.completion_event_id = appended.event_id - run.result_json = canonical_post_submit_bytes( + run.result_json = canonical_checker_bytes( result, exclude={"result_digest"} ).decode("utf-8") run.result_digest = result.result_digest diff --git a/backend/app/modules/checkers/execution_coordination.py b/backend/app/modules/checkers/execution_coordination.py index 577732a4b..184075688 100644 --- a/backend/app/modules/checkers/execution_coordination.py +++ b/backend/app/modules/checkers/execution_coordination.py @@ -20,9 +20,12 @@ VerifiedMaterialFacts, VerifiedEvaluationCompletion, ) -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmitCurrentResultReference, +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, + validate_execution_request, +) +from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmitCurrentResultReference, ) from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, @@ -50,10 +53,10 @@ def __init__(self, session: AsyncSession, *, tasks: EvaluationTaskGuard): self._tasks = tasks async def reserve_current_evaluation( - self, request: PostSubmissionEvaluationRequest + self, request: PostSubmissionExecutionRequest ) -> EvaluationReservation: """Serialize initial creation, exact replay and the next current generation.""" - request = PostSubmissionEvaluationRequest.model_validate(request) + request = validate_execution_request(request) with self._session.no_autoflush: can_create = await self._lock_task_scope(request) if not can_create: @@ -64,7 +67,7 @@ async def reserve_current_evaluation( return await self._reserve_after_task_lock(request) async def _reserve_after_task_lock( - self, request: PostSubmissionEvaluationRequest, *, terminal_source_id: UUID | None = None, + self, request: PostSubmissionExecutionRequest, *, terminal_source_id: UUID | None = None, ) -> EvaluationReservation: """Use the existing CHECKERS locks after the caller has retained TASK custody.""" require_transaction(self._session) @@ -128,8 +131,12 @@ async def _reserve_after_task_lock( supersedes_checker_run_id=prior.id if prior else None, locked_guide_version=context.guide_version, locked_post_submit_checker_policy_id=str(context.post_policy_id), - locked_post_submit_checker_policy_version=context.post_policy_version, + locked_post_submit_checker_policy_version=( + context.post_policy_version + ), locked_post_submit_checker_policy_hash=context.post_policy_hash, + selected_post_submit_policy_sha256=request.policy.policy_sha256, + selected_post_submit_policy_body=request.policy.model_dump(mode="json"), locked_review_policy_id=str(context.review_policy_id), locked_review_policy_generation=context.review_generation, locked_review_policy_hash=context.review_hash, @@ -187,7 +194,7 @@ async def read_retry_delivery( submission_id=submission_id, event_id=event_id, ) - async def _lock_task_scope(self, request: PostSubmissionEvaluationRequest) -> bool: + async def _lock_task_scope(self, request: PostSubmissionExecutionRequest) -> bool: """Retain root-transaction TASK custody before any coordination fence lock.""" require_transaction(self._session) connection = await self._session.connection() @@ -205,7 +212,7 @@ async def _lock_task_scope(self, request: PostSubmissionEvaluationRequest) -> bo raise CheckerExecutionUnavailable("checker_reservation_scope_unavailable") return can_create - async def _replay(self, request: PostSubmissionEvaluationRequest) -> CheckerRun | None: + async def _replay(self, request: PostSubmissionExecutionRequest) -> CheckerRun | None: """Read an exact stored envelope; never repair or restore its current fence.""" replay = await self._session.scalar(select(CheckerRun).where( CheckerRun.evaluation_request_id == str(request.evaluation_request_id), @@ -219,19 +226,19 @@ async def _replay(self, request: PostSubmissionEvaluationRequest) -> CheckerRun return replay async def read_current_result( - self, request: PostSubmissionEvaluationRequest + self, request: PostSubmissionExecutionRequest ) -> CompletedEvaluation: """Return only a locked completed result matching the entire current request.""" - request = PostSubmissionEvaluationRequest.model_validate(request) + request = validate_execution_request(request) with self._session.no_autoflush: await self._lock_task_scope(request) run = await ExecutionRepository(self._session).lock_current(request) if run.status != "completed": raise CheckerExecutionUnavailable("checker_current_result_unavailable") result = stored_result(run) - reference = PostSubmitCurrentResultReference( + reference = SelectedPostSubmitCurrentResultReference( **result.model_dump( - include=set(PostSubmitCurrentResultReference.model_fields) - {"schema_version"} + include=set(SelectedPostSubmitCurrentResultReference.model_fields) - {"schema_version"} ) ) reference.validate_result(result) @@ -316,7 +323,7 @@ async def require_current_execution(self, facts): class CheckerOutputReservations: - """Current structural handlers have exactly zero output slots.""" + """Selected evidence-only checker executions have no artifact output slots.""" def __init__(self, session: AsyncSession): """Resolve output custody within the supplied CHECKERS session.""" diff --git a/backend/app/modules/checkers/execution_repository.py b/backend/app/modules/checkers/execution_repository.py index b799d382f..fdcd59dab 100644 --- a/backend/app/modules/checkers/execution_repository.py +++ b/backend/app/modules/checkers/execution_repository.py @@ -13,11 +13,13 @@ EvaluationReservation, ExecutionLease, ) -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, + PostSubmissionExecutionResult, + validate_stored_execution_request, + validate_stored_execution_result, ) -from app.modules.checkers.api.post_submit_catalogue import canonical_post_submit_bytes +from app.modules.checkers.api.canonical_values import canonical_checker_bytes from app.modules.checkers.models import CheckerResult, CheckerRun, CheckerSubmissionFence @@ -27,9 +29,9 @@ def require_transaction(session: AsyncSession) -> None: raise CheckerExecutionUnavailable("checker_caller_transaction_required") -def request_text(request: PostSubmissionEvaluationRequest) -> str: +def request_text(request: PostSubmissionExecutionRequest) -> str: """Serialize canonical request facts without the separately stored digest.""" - return canonical_post_submit_bytes(request, exclude={"request_sha256"}).decode("utf-8") + return canonical_checker_bytes(request, exclude={"request_sha256"}).decode("utf-8") def reservation(run: CheckerRun) -> EvaluationReservation: @@ -43,20 +45,24 @@ def reservation(run: CheckerRun) -> EvaluationReservation: ) -def stored_request(run: CheckerRun) -> PostSubmissionEvaluationRequest: +def stored_request(run: CheckerRun) -> PostSubmissionExecutionRequest: """Reconstitute and validate the stored request with its digest.""" body = json.loads(run.request_json) body["request_sha256"] = run.request_digest - return PostSubmissionEvaluationRequest.model_validate_json(json.dumps(body)) + return validate_stored_execution_request( + json.dumps(body, separators=(",", ":"), sort_keys=True) + ) -def stored_result(run: CheckerRun) -> PostSubmissionEvaluationResult: +def stored_result(run: CheckerRun) -> PostSubmissionExecutionResult: """Validate retained results against their stored request and run identities.""" if run.result_json is None or run.result_digest is None: raise CheckerExecutionUnavailable("checker_result_unavailable") body = json.loads(run.result_json) body["result_digest"] = run.result_digest - result = PostSubmissionEvaluationResult.model_validate_json(json.dumps(body)) + result = validate_stored_execution_result( + json.dumps(body, separators=(",", ":"), sort_keys=True) + ) result.validate_request(stored_request(run)) if (str(result.attempt_id), str(result.result_id)) != (run.id, run.result_id): raise CheckerExecutionUnavailable("checker_result_unavailable") @@ -70,7 +76,7 @@ def __init__(self, session: AsyncSession): """Bind repository operations to the supplied transaction session.""" self.session = session - async def lock_current(self, request: PostSubmissionEvaluationRequest) -> CheckerRun: + async def lock_current(self, request: PostSubmissionExecutionRequest) -> CheckerRun: """Qualify stored owner facts before locking the single fence, then run.""" require_transaction(self.session) match = ( @@ -121,7 +127,7 @@ async def now(self) -> datetime: return await self.session.scalar(select(func.clock_timestamp())) async def require_lease( - self, request: PostSubmissionEvaluationRequest, lease: ExecutionLease + self, request: PostSubmissionExecutionRequest, lease: ExecutionLease ) -> CheckerRun: """Lock current custody and reject stale, expired or substituted leases.""" run = await self.lock_current(request) @@ -136,24 +142,32 @@ async def require_lease( raise CheckerExecutionUnavailable("checker_execution_lease_unavailable") return run - async def write_members(self, run: CheckerRun, result: PostSubmissionEvaluationResult) -> None: + async def write_members(self, run: CheckerRun, result: PostSubmissionExecutionResult) -> None: """Flush ordered closed results under the locked running parent.""" - for order, member in enumerate(result.member_results): + for member in result.execution.external_members if result.execution else (): self.session.add( CheckerResult( id=str(new_record_id()), checker_run_id=run.id, task_id=run.task_id, submission_id=run.submission_id, - member_order=order, - checker_name=member.checker_id, - definition_version=member.definition_version, - implementation_version=member.implementation_version, - status=member.status, - code=member.code, - failure_category=member.failure_category, - severity=member.severity, - counters=[item.model_dump(mode="json") for item in member.counters], + member_order=member.order, + checker_name=member.requirement_id, + definition_version=member.result.schema_version, + implementation_version=member.registry_entry_digest, + status=member.result.verdict or "infrastructure_failed", + code=( + "passed" + if member.result.verdict == "passed" + else member.result.findings[0].code + if member.result.findings + else "invalid_output" + ), + failure_category=( + "none" if member.result.verdict == "passed" else "submission_structure" + ), + severity=("info" if member.result.verdict == "passed" else "error"), + counters=[], ) ) await self.session.flush() diff --git a/backend/app/modules/checkers/execution_results.py b/backend/app/modules/checkers/execution_results.py index 68c7c0e35..3c23adbd9 100644 --- a/backend/app/modules/checkers/execution_results.py +++ b/backend/app/modules/checkers/execution_results.py @@ -2,9 +2,9 @@ from dataclasses import dataclass -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, + PostSubmissionExecutionResult, ) @@ -20,57 +20,25 @@ class ResultClassification: def classify_result( - request: PostSubmissionEvaluationRequest, result: PostSubmissionEvaluationResult + request: PostSubmissionExecutionRequest, result: PostSubmissionExecutionResult ) -> ResultClassification: """Validate every selected member before deriving policy-governed routing.""" result.validate_request(request) if result.outcome == "infrastructure_failed": return ResultClassification("not_evaluated", 0, 0, 0, 0) - members = result.member_results - blocking = tuple( - m - for m in members - if m.status != "passed" and m.severity in request.policy.blocking_severities - ) - if any(m.failure_category == "task_configuration" for m in blocking): - routing = "task_setup_blocked" - elif blocking: - routing = "needs_revision" - else: - routing = "allow_review" + members = result.execution.external_members if result.execution else () + failed = sum(member.result.verdict == "failed" for member in members) return ResultClassification( - routing, - sum(m.status == "passed" for m in members), - sum(m.status == "warning" for m in members), - sum(m.status == "failed" for m in members), - len(blocking), + "needs_revision" if failed else "allow_review", + sum(member.result.verdict == "passed" for member in members), + 0, + failed, + failed, ) -RESULT_MESSAGES = { +RESULT_STATUS_MESSAGES = { "passed": ("Check passed.", None), - "packet_fields_missing": ( - "Required submission fields are missing.", - "Complete the required submission fields.", - ), - "policy_context_invalid": ("The locked policy context is inconsistent.", None), - "evidence_missing": ("Required evidence is missing.", "Include the required evidence."), - "evidence_structure_invalid": ( - "Evidence references are invalid.", - "Correct the evidence references.", - ), - "required_files_missing": ("Required files are missing.", "Include the required files."), - "forbidden_path_present": ( - "The submission includes a forbidden path.", - "Remove the forbidden content.", - ), - "attestation_missing": ( - "Required attestation is missing.", - "Complete the required attestation.", - ), - "placeholder_signal": ( - "A possible placeholder needs attention.", - "Check the flagged submission structure.", - ), - "acceptance_criteria_missing": ("Task acceptance criteria are missing.", None), + "warning": ("Check reported a warning.", "Review the project guide and check findings."), + "failed": ("Project check failed.", "Review the project guide and check findings."), } diff --git a/backend/app/modules/checkers/external_registry.py b/backend/app/modules/checkers/external_registry.py index a95e41b71..8f5f7fca1 100644 --- a/backend/app/modules/checkers/external_registry.py +++ b/backend/app/modules/checkers/external_registry.py @@ -90,9 +90,7 @@ async def lock_registration_scopes( {"scope": value}, ) - async def by_operation( - self, operation_id: UUID - ) -> ExternalCheckerRegistryEntryRecord | None: + async def by_operation(self, operation_id: UUID) -> ExternalCheckerRegistryEntryRecord | None: """Read the row reserved by one idempotent registration operation.""" return await self._session.scalar( select(ExternalCheckerRegistryEntryRecord).where( @@ -122,6 +120,18 @@ async def by_exact_id( populate_existing=True, ) + async def all_entries(self) -> tuple[ExternalCheckerRegistryEntryRecord, ...]: + """Read the complete immutable inventory in one deterministic order.""" + return tuple( + await self._session.scalars( + select(ExternalCheckerRegistryEntryRecord).order_by( + ExternalCheckerRegistryEntryRecord.phase, + ExternalCheckerRegistryEntryRecord.capability_id, + ExternalCheckerRegistryEntryRecord.capability_version, + ) + ) + ) + async def add(self, row: ExternalCheckerRegistryEntryRecord) -> None: """Flush and refresh one row without committing the caller transaction.""" self._session.add(row) @@ -129,6 +139,58 @@ async def add(self, row: ExternalCheckerRegistryEntryRecord) -> None: await self._session.refresh(row) +class ExternalCheckerRegistryReader: + """Authority-neutral exact metadata reads for trusted policy compilation.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._repository = ExternalCheckerRegistryRepository(session) + + def _require_root_transaction(self) -> None: + transaction = self._session.sync_session.get_transaction() + if ( + transaction is None + or not transaction.is_active + or self._session.in_nested_transaction() + ): + raise ExternalCheckerRegistryUnavailable("external_checker_registry_unavailable") + + async def read_exact( + self, registry_entry_id: UUID, entry_digest: str + ) -> ExternalCheckerRegistryEntry: + self._require_root_transaction() + if type(registry_entry_id) is not UUID or type(entry_digest) is not str: + raise ExternalCheckerRegistryUnavailable("external_checker_registry_unavailable") + row = await self._repository.by_exact_id(registry_entry_id) + if row is None or row.entry_digest != entry_digest: + raise ExternalCheckerRegistryUnavailable("external_checker_registry_unavailable") + return _row_entry(row) + + async def read_identity( + self, *, capability_id: str, capability_version: str, phase: str + ) -> ExternalCheckerRegistryEntry: + self._require_root_transaction() + if ( + type(capability_id) is not str + or type(capability_version) is not str + or phase not in {"pre_submit", "post_submit"} + ): + raise ExternalCheckerRegistryUnavailable("external_checker_registry_unavailable") + row = await self._repository.by_identity( + capability_id=capability_id, + capability_version=capability_version, + phase=phase, + ) + if row is None: + raise ExternalCheckerRegistryUnavailable("external_checker_registry_unavailable") + return _row_entry(row) + + async def list_entries(self) -> tuple[ExternalCheckerRegistryEntry, ...]: + """Expose every immutable capability to a new compilation attempt.""" + self._require_root_transaction() + return tuple(_row_entry(row) for row in await self._repository.all_entries()) + + class ExternalCheckerRegistryService: """Publish immutable registry metadata without granting execution authority.""" @@ -150,9 +212,7 @@ def _require_root_transaction(self) -> None: or not transaction.is_active or self._session.in_nested_transaction() ): - raise ExternalCheckerRegistryUnavailable( - "external_checker_registry_unavailable" - ) + raise ExternalCheckerRegistryUnavailable("external_checker_registry_unavailable") async def register( self, request: ExternalCheckerRegistrationRequest @@ -176,9 +236,7 @@ async def register( "external_checker_registry_unavailable" ) from exc if authority.actor_profile_id != request.actor_profile_id: - raise ExternalCheckerRegistryUnavailable( - "external_checker_registry_unavailable" - ) + raise ExternalCheckerRegistryUnavailable("external_checker_registry_unavailable") await self._repository.lock_registration_scopes( request.operation_id, capability_id=request.spec.capability_id, @@ -188,15 +246,16 @@ async def register( replay = await self._repository.by_operation(request.operation_id) if replay is not None: if not self._matches_request(replay, request): - raise ExternalCheckerRegistryConflict( - "external_checker_registry_conflict" - ) + raise ExternalCheckerRegistryConflict("external_checker_registry_conflict") return _row_entry(replay) - if await self._repository.by_identity( - capability_id=request.spec.capability_id, - capability_version=request.spec.capability_version, - phase=request.spec.phase, - ) is not None: + if ( + await self._repository.by_identity( + capability_id=request.spec.capability_id, + capability_version=request.spec.capability_version, + phase=request.spec.phase, + ) + is not None + ): raise ExternalCheckerRegistryConflict("external_checker_registry_conflict") spec = request.spec values = { @@ -213,9 +272,7 @@ async def register( **spec.resources.model_dump(), "entry_digest": spec.spec_digest, "registered_by_actor_profile_id": str(request.actor_profile_id), - "authorization_decision_event_id": str( - authority.authorization_decision_event_id - ), + "authorization_decision_event_id": str(authority.authorization_decision_event_id), } row = ExternalCheckerRegistryEntryRecord(**values) await self._repository.add(row) @@ -225,17 +282,23 @@ async def read_exact( self, registry_entry_id: UUID, entry_digest: str ) -> ExternalCheckerRegistryEntry: """Read one exact row; never select a current/latest version.""" - self._require_root_transaction() - if type(registry_entry_id) is not UUID or type(entry_digest) is not str: - raise ExternalCheckerRegistryUnavailable( - "external_checker_registry_unavailable" - ) - row = await self._repository.by_exact_id(registry_entry_id) - if row is None or row.entry_digest != entry_digest: - raise ExternalCheckerRegistryUnavailable( - "external_checker_registry_unavailable" - ) - return _row_entry(row) + return await ExternalCheckerRegistryReader(self._session).read_exact( + registry_entry_id, entry_digest + ) + + async def read_identity( + self, *, capability_id: str, capability_version: str, phase: str + ) -> ExternalCheckerRegistryEntry: + """Resolve the immutable identity during trusted approval compilation.""" + return await ExternalCheckerRegistryReader(self._session).read_identity( + capability_id=capability_id, + capability_version=capability_version, + phase=phase, + ) + + async def list_entries(self) -> tuple[ExternalCheckerRegistryEntry, ...]: + """Expose the complete immutable capability inventory.""" + return await ExternalCheckerRegistryReader(self._session).list_entries() @staticmethod def _matches_request( diff --git a/backend/app/modules/checkers/history.py b/backend/app/modules/checkers/history.py index 0d57d1964..4067f5659 100644 --- a/backend/app/modules/checkers/history.py +++ b/backend/app/modules/checkers/history.py @@ -1,13 +1,13 @@ """Fixed history projection from closed results and the sole currentness fence.""" -from sqlalchemy import select, literal_column, tuple_ +from sqlalchemy import select, tuple_ from app.modules.checkers.models import CheckerRun, CheckerResult, CheckerSubmissionFence from app.modules.checkers.api.history import ( ContributorCheckerHistory, ManagementCheckerHistory, ContributorCheckerResult, ManagementCheckerResult, ) -from app.modules.checkers.execution_results import RESULT_MESSAGES +from app.modules.checkers.execution_results import RESULT_STATUS_MESSAGES class CheckerHistoryRepository: @@ -38,9 +38,9 @@ async def read(self, *, submission_id, task_id, manager, run_id=None, limit=25, rows = (await self._session.execute(statement.order_by(CheckerRun.created_at, CheckerRun.id).limit(limit + 1))).mappings().all() values = [] for row in rows[:limit]: - columns = [getattr(CheckerResult, name) for name in ("id", "checker_name", "status", "severity", "code", "failure_category")] + columns = [getattr(CheckerResult, name) for name in ("id", "checker_name", "status", "severity", "failure_category")] if manager: - columns.append(literal_column("(checker_results.status<>'passed' AND (checker_runs.request_json::jsonb #> '{policy,blocking_severities}') ? checker_results.severity)").label("blocks_review")) + columns.append((CheckerResult.status == "failed").label("blocks_review")) result_query = select(*columns).join(CheckerRun, CheckerRun.id == CheckerResult.checker_run_id).where( CheckerResult.checker_run_id == row["id"], CheckerResult.submission_id == str(submission_id), CheckerResult.task_id == str(task_id), @@ -53,7 +53,7 @@ async def read(self, *, submission_id, task_id, manager, run_id=None, limit=25, results = (await self._session.execute(result_query.order_by(CheckerResult.member_order))).mappings().all() projected = [] for member in results: - message, fix = RESULT_MESSAGES[member["code"]] + message, fix = RESULT_STATUS_MESSAGES[member["status"]] visible = member["failure_category"] != "task_configuration" item = {key: member[key] for key in ("id", "checker_name", "status", "severity")} item.update(worker_message=message if visible else None, worker_suggested_fix=fix if visible else None) diff --git a/backend/app/modules/checkers/models.py b/backend/app/modules/checkers/models.py index 5ba4eda02..6a404f2d8 100644 --- a/backend/app/modules/checkers/models.py +++ b/backend/app/modules/checkers/models.py @@ -8,6 +8,7 @@ from sqlalchemy import Uuid from sqlalchemy import ( BigInteger, + Boolean, CheckConstraint, DateTime, ForeignKey, @@ -224,6 +225,13 @@ class CheckerRun(Base): ForeignKeyConstraint(["task_id", "project_id"], ["workstream_tasks.id", "workstream_tasks.project_id"], name="fk_checker_runs_task_project"), CheckConstraint("phase = 'post_submission'", name="phase"), + CheckConstraint( + "(selected_post_submit_policy_sha256 is null and " + "selected_post_submit_policy_body is null) or " + "(selected_post_submit_policy_sha256 ~ '^sha256:[0-9a-f]{64}$' and " + "jsonb_typeof(selected_post_submit_policy_body)='object')", + name="selected_post_submit_policy", + ), CheckConstraint("evaluation_generation > 0 and worker_lease_generation >= 0", name="generations"), CheckConstraint("status in ('queued','running','completed','infrastructure_failed')", name="state"), CheckConstraint("octet_length(request_json) <= 1048576 and request_digest = 'sha256:' || encode(sha256(convert_to(request_json, 'UTF8')), 'hex')", name="request_digest"), @@ -286,6 +294,8 @@ class CheckerRun(Base): nullable=False, ) locked_post_submit_checker_policy_hash: Mapped[str] = mapped_column(String(71), nullable=False) + selected_post_submit_policy_sha256: Mapped[str | None] = mapped_column(String(71)) + selected_post_submit_policy_body: Mapped[dict | None] = mapped_column(JSONB) locked_review_policy_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) locked_review_policy_generation: Mapped[int] = mapped_column(Integer, nullable=False) locked_review_policy_hash: Mapped[str] = mapped_column(String(71), nullable=False) @@ -406,3 +416,124 @@ class CheckerRetryReceipt(Base): resource_digest: Mapped[str] = mapped_column(String(71), nullable=False) resource_json: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class SelectedPreSubmitRun(Base): + """Durable selected PRE lease and terminal facts before any Submission exists.""" + + __tablename__ = "selected_pre_submit_runs" + __table_args__ = ( + CheckConstraint( + "(get_byte(uuid_send(id), 6) >> 4) = 7 and " + "(get_byte(uuid_send(id), 8) & 192) = 128", + name="id_uuid7", + ), + ForeignKeyConstraint( + ["task_id", "project_id"], + ["workstream_tasks.id", "workstream_tasks.project_id"], + name="fk_selected_pre_run_task_project", + ), + ForeignKeyConstraint( + ["assignment_id", "task_id", "actor_profile_id"], + ["task_assignments.id", "task_assignments.task_id", "task_assignments.contributor_id"], + name="fk_selected_pre_run_assignment", + ), + ForeignKeyConstraint( + ["identity_link_id", "actor_profile_id"], + ["actor_identity_links.id", "actor_identity_links.actor_profile_id"], + name="fk_selected_pre_run_actor_link", + ), + CheckConstraint( + "status in ('queued','running','completed','infrastructure_failed')", + name="status", + ), + CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$' and " + "policy_sha256 ~ '^sha256:[0-9a-f]{64}$' and " + "default_behavior_sha256 ~ '^sha256:[0-9a-f]{64}$' and " + "default_input_sha256 ~ '^sha256:[0-9a-f]{64}$' and " + "archive_sha256 ~ '^sha256:[0-9a-f]{64}$' and " + "semantic_manifest_sha256 ~ '^sha256:[0-9a-f]{64}$' and " + "(terminal_digest is null or terminal_digest ~ '^sha256:[0-9a-f]{64}$')", + name="digests", + ), + CheckConstraint("archive_byte_count > 0", name="archive_size"), + CheckConstraint( + "execution_budget_ms between 100 and 14400000", + name="execution_budget", + ), + CheckConstraint("lease_generation >= 0", name="lease_generation"), + CheckConstraint( + "(status='queued' and lease_id is null and lease_expires_at is null " + "and terminal_json is null and terminal_digest is null and eligible is null " + "and started_at is null and completed_at is null " + "and execute_evidence_id is null and finalize_evidence_id is null) or " + "(status='running' and lease_id is not null and lease_generation > 0 " + "and lease_expires_at is not null and terminal_json is null " + "and terminal_digest is null and eligible is null and started_at is not null " + "and completed_at is null " + "and execute_evidence_id is not null and finalize_evidence_id is null) or " + "(status='completed' and lease_id is not null and lease_generation > 0 " + "and lease_expires_at is not null and terminal_json is not null " + "and terminal_digest is not null and eligible is not null " + "and started_at is not null and completed_at is not null " + "and execute_evidence_id is not null and finalize_evidence_id is not null) or " + "(status='infrastructure_failed' and lease_id is not null " + "and lease_generation > 0 and lease_expires_at is not null " + "and terminal_json is not null and terminal_digest is not null " + "and eligible is null and started_at is not null and completed_at is not null " + "and execute_evidence_id is not null and finalize_evidence_id is not null)", + name="lifecycle", + ), + CheckConstraint( + "jsonb_typeof(request_json)='object' and " + "(terminal_json is null or jsonb_typeof(terminal_json)='object')", + name="json_objects", + ), + UniqueConstraint("request_digest", name="request_digest"), + UniqueConstraint( + "actor_profile_id", + "idempotency_key", + name="uq_selected_pre_run_actor_key", + ), + Index("ix_selected_pre_runs_task", "task_id"), + Index("ix_selected_pre_runs_status", "status"), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + actor_profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + identity_link_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + project_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + task_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + assignment_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + idempotency_key: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + prepared_generation_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + request_json: Mapped[dict] = mapped_column(JSONB(), nullable=False) + request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + policy_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + default_behavior_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + default_input_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + archive_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + archive_byte_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + semantic_manifest_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + execution_budget_ms: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + lease_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + lease_generation: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + execute_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("audit_events.id", ondelete="RESTRICT"), + ) + finalize_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("audit_events.id", ondelete="RESTRICT"), + ) + terminal_json: Mapped[dict | None] = mapped_column(JSONB()) + terminal_digest: Mapped[str | None] = mapped_column(String(71)) + eligible: Mapped[bool | None] = mapped_column(Boolean) + queued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/modules/checkers/post_submit_catalogue.py b/backend/app/modules/checkers/post_submit_catalogue.py deleted file mode 100644 index 8126f0b69..000000000 --- a/backend/app/modules/checkers/post_submit_catalogue.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Validate installed handlers against the one current public catalogue.""" - -from __future__ import annotations -from typing import TYPE_CHECKING -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitCatalogue, - current_post_submit_catalogue, -) - -if TYPE_CHECKING: - from app.modules.checkers.runner import CheckerRegistry - - -def build_post_submit_catalogue(registry: CheckerRegistry) -> PostSubmitCatalogue: - """Require exact metadata and callable installation parity.""" - catalogue = current_post_submit_catalogue() - if registry.names() != {item.capability_id for item in catalogue.definitions}: - raise ValueError("post-submit registered catalogue is incomplete") - for definition in catalogue.definitions: - entry = registry.resolve(definition.capability_id) - if entry.definition != definition or entry.checker.name != definition.capability_id: - raise ValueError("post-submit registered implementation mismatch") - return catalogue diff --git a/backend/app/modules/checkers/post_submit_contracts.py b/backend/app/modules/checkers/post_submit_contracts.py deleted file mode 100644 index c30fb18c4..000000000 --- a/backend/app/modules/checkers/post_submit_contracts.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Owner-local construction helpers for hash-bound post-submit facts.""" - -from __future__ import annotations - -from pydantic import TypeAdapter - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationResult, -) - - -_RESULT_FIELDS = { - name: TypeAdapter(field.rebuild_annotation()) - for name, field in PostSubmissionEvaluationResult.model_fields.items() -} - - -def make_post_submit_result(**fields: object) -> PostSubmissionEvaluationResult: - """Derive a result identity before validating its closed outcome shape.""" - if "result_digest" in fields: - raise ValueError("result identity is derived, not caller selected") - fields = { - name: _RESULT_FIELDS[name].validate_python(value) if name in _RESULT_FIELDS else value - for name, value in fields.items() - } - candidate = PostSubmissionEvaluationResult.model_construct( - **fields, result_digest="sha256:" + "0" * 64 - ) - body = candidate.model_dump(mode="json", exclude={"result_digest"}) - fields["result_digest"] = canonical_json_hash(body) - return PostSubmissionEvaluationResult.model_validate(fields) diff --git a/backend/app/modules/checkers/post_submit_implementations.py b/backend/app/modules/checkers/post_submit_implementations.py deleted file mode 100644 index 374172374..000000000 --- a/backend/app/modules/checkers/post_submit_implementations.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Private detached-input adaptation over the existing registered structural rules.""" - -from __future__ import annotations - -from dataclasses import dataclass -import re - -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmitEvidenceEntry, - PostSubmitMemberResult, -) -from app.modules.checkers.api.post_submit_catalogue import PostSubmitDefinition -from app.modules.checkers.runner import ( - CheckerContext, - CheckerOutcome, - CheckerRegistry, - _normalize_artifact_path, -) - - -def _copied_artifact_path(value: str) -> str: - """Normalize valid private paths; let the actual handler classify invalid ones.""" - try: - return _normalize_artifact_path(value) - except ValueError: - return value - - -@dataclass(frozen=True) -class _TaskView: - """Detached criteria consumed by the existing presence handler.""" - - acceptance_criteria: str - - -@dataclass(frozen=True) -class _EvidenceView: - """Private copied evidence values for the existing structural helpers.""" - - label: str - uri: str | None - hash: str | None - type: str - metadata_json: dict[str, str] - - @classmethod - def from_facts(cls, facts: PostSubmitEvidenceEntry) -> _EvidenceView: - """Copy only closed evidence keys for the existing pure presence rule.""" - keys = ("key", "policy_key", "evidence_key", "required_evidence_key") - metadata = {key: value for key in keys if (value := getattr(facts, key)) is not None} - return cls( - facts.label, - _copied_artifact_path(facts.uri) - if facts.uri and not re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", facts.uri) - else facts.uri, - facts.hash, facts.type, metadata, - ) - - -@dataclass(frozen=True) -class _SubmissionView: - """Private copies shield immutable source facts from handler mutation.""" - - summary: str - package_hash: str - worker_attestation: str - artifact_hash_manifest: list[dict] - evidence_items: tuple[_EvidenceView, ...] - - -def detached_checker_context(request: PostSubmissionEvaluationRequest) -> CheckerContext: - """Use copies for structural helpers; no original immutable fact can be mutated.""" - request = PostSubmissionEvaluationRequest.model_validate(request) - data = request.structural_input - policy = data.policy_inputs - return CheckerContext( - task=_TaskView(data.criteria), - submission=_SubmissionView( - data.summary, - data.package_hash, - data.worker_attestation, - [ - {**item.model_dump(mode="json"), "artifact": _copied_artifact_path(item.artifact)} - for item in data.manifest - ], - tuple(_EvidenceView.from_facts(item) for item in data.evidence), - ), - required_checker_names=frozenset( - entry.checker_id - for entry in request.policy.entries - if entry.classification == "project_required" - ), - warning_checker_names=frozenset( - entry.checker_id - for entry in request.policy.entries - if entry.classification == "project_warning" - ), - blocking_severities=frozenset(request.policy.blocking_severities), - effective_policy={ - "required_artifacts": [ - {"path": value, "required": True} for value in policy.required_artifact_paths - ], - "required_evidence": [ - {"key": value, "required": True} for value in policy.required_evidence_keys - ], - "forbidden_artifacts": [ - {"pattern": value} for value in policy.forbidden_artifact_patterns - ], - "attestation_terms": list(policy.required_attestation_terms), - }, - observed_context=data.observed_context, - expected_context=request.expected_context, - ) - - -def bounded_structural_result( - definition: PostSubmitDefinition, outcome: CheckerOutcome -) -> PostSubmitMemberResult: - """Discard free-form text/metadata and require the registered claim's closed outcome.""" - if outcome.checker_name != definition.capability_id: - raise ValueError("post-submit handler returned another checker identity") - passed = outcome.status == "passed" - result = PostSubmitMemberResult( - checker_id=definition.capability_id, - implementation_version=definition.implementation_version, - status=outcome.status, - code="passed" if passed else definition.failure_code, - failure_category="none" if passed else definition.failure_category, - severity=outcome.severity, - ) - if not passed and (result.status, result.severity) != ( - definition.failure_status, - definition.failure_severity, - ): - raise ValueError("post-submit handler returned an unsupported outcome") - return result - - -async def evaluate_registered_structural_member( - registry: CheckerRegistry, - definition: PostSubmitDefinition, - request: PostSubmissionEvaluationRequest, -) -> PostSubmitMemberResult: - """Private conformance seam; no product route or complete live phase executor.""" - selected = request.catalogue.definition(definition.capability_id, definition.capability_version) - if selected != definition: - raise ValueError("post-submit conformance definition mismatch") - definition.validate_configuration({}) - registration = registry.resolve(definition.capability_id) - if registration.definition != definition: - raise ValueError("post-submit installed definition mismatch") - context = detached_checker_context(request) - outcome = await registration.checker.run(context) - return bounded_structural_result(definition, outcome) diff --git a/backend/app/modules/checkers/pre_submit_execution.py b/backend/app/modules/checkers/pre_submit_execution.py deleted file mode 100644 index a9d5239d7..000000000 --- a/backend/app/modules/checkers/pre_submit_execution.py +++ /dev/null @@ -1,644 +0,0 @@ -"""Hidden plan-bound execution of Workstream-default pre-submission checks.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum -from pathlib import Path -from pathlib import PurePosixPath -import threading -from typing import BinaryIO -from fnmatch import fnmatchcase -from uuid import UUID - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api import ( - ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, - EffectivePreSubmissionExecutionPlan, - EffectivePreSubmissionPlanEntry, - PreSubmissionExecutionEntryFacts, - PreSubmissionExecutionFacts, - PreSubmissionInfrastructureUnavailableError, - PRE_SUBMISSION_RESULT_METADATA_KEYS, - SubmissionPacketView, - validate_pre_submission_execution_facts, -) -from app.modules.artifacts.sources import ArtifactCommitment -from app.modules.artifacts.submission_archive import ( - SealedSubmissionTree, - SubmissionArchiveInspectionResult, - SubmissionArchiveInspector, -) -from app.modules.artifacts.submission_manifest import ( - SubmissionChangeGateResult, - SubmissionManifest, - build_submission_manifest, -) -from app.modules.checkers.catalogue import ( - PreSubmissionCheckerCatalogue, - PreSubmissionCheckerPhase, - PreSubmissionCheckerState, - PreSubmissionPlatformCapability, - PreSubmissionPolicyPrimitive, -) -from app.modules.checkers.api.artifact_paths import is_canonical_relative_path -from app.modules.checkers.pre_submit_defaults import ( - attestation_validation_facts, - matched_low_quality_patterns, -) - - -_EXECUTED_PHASES = frozenset( - { - PreSubmissionCheckerPhase.CUSTODY.value, - PreSubmissionCheckerPhase.IDENTITY.value, - PreSubmissionCheckerPhase.MATERIALIZATION.value, - PreSubmissionCheckerPhase.DEFAULT_POLICY.value, - } -) -_FORBIDDEN_EXACT_NAMES = frozenset({".env", "id_rsa", "id_ed25519"}) -_FORBIDDEN_DIRECTORY_NAMES = frozenset({".git"}) -_FORBIDDEN_SUFFIXES = (".pem", ".key") -class DefaultPreSubmissionExecutionError(RuntimeError): - """Fail hidden execution without creating a durable checker effect.""" - - -class PreSubmissionInfrastructureUnavailable(DefaultPreSubmissionExecutionError): - """Fail closed for an impossible or disabled mandatory execution state.""" - - -class PreSubmissionResultStatus(StrEnum): - """Closed non-review status vocabulary for every pre-submit result.""" - - PASSED = "passed" - WARNING = "warning" - ADVISORY_DISABLED = "advisory_disabled" - DEPENDENCY_NOT_RUN = "dependency_not_run" - FAILED = "failed" - - -@dataclass(frozen=True, slots=True) -class PreSubmissionResultDefinition: - """Authority-neutral identity for one catalogue-owned definition.""" - - dispatch_authority: str - definition_id: str - definition_version: str - public_name: str - source: str - - -@dataclass(frozen=True, slots=True) -class PreSubmissionResultPolicyTrace: - """Exact locked policy trace for one result.""" - - effective_plan_sha256: str - rule_instance_id: str | None - locked_policy_sha256: str - - -@dataclass(frozen=True, slots=True) -class PreSubmissionEntryResult: - """One canonical bounded and path-redacted pre-submit result.""" - - schema_version: str - definition: PreSubmissionResultDefinition - policy_trace: PreSubmissionResultPolicyTrace - phase: str - order: int - classification: str - severity: str - status: PreSubmissionResultStatus - failure_code: str | None - message_code: str - metadata: tuple[tuple[str, int], ...] = () - - -@dataclass(frozen=True, slots=True) -class PreSubmissionExecutionCustody: - """Exact server-owned artifact facts observed by the sealed-tree execution.""" - - prepared_generation_id: UUID - archive_sha256: str - archive_byte_count: int - semantic_manifest_sha256: str - storage_scheme: str - - -@dataclass(frozen=True, slots=True) -class PreSubmissionExecutionResult: - """Complete canonical platform-plus-project result after scratch cleanup.""" - - plan_sha256: str - custody: PreSubmissionExecutionCustody - eligible: bool - entries: tuple[PreSubmissionEntryResult, ...] - - def bounded_facts(self) -> PreSubmissionExecutionFacts: - """Project CHECKER outcomes without leaking ART-owned custody.""" - return PreSubmissionExecutionFacts( - plan_sha256=self.plan_sha256, - eligible=self.eligible, - entries=tuple( - PreSubmissionExecutionEntryFacts( - dispatch_authority=entry.definition.dispatch_authority, - definition_id=entry.definition.definition_id, - definition_version=entry.definition.definition_version, - public_name=entry.definition.public_name, - policy_source=entry.definition.source, - effective_plan_sha256=entry.policy_trace.effective_plan_sha256, - rule_instance_id=entry.policy_trace.rule_instance_id, - locked_policy_sha256=entry.policy_trace.locked_policy_sha256, - phase=entry.phase, - order=entry.order, - classification=entry.classification, - severity=entry.severity, - checker_execution_status=entry.status.value, - failure_code=entry.failure_code, - message_code=entry.message_code, - metadata=tuple( - (key, value) - for key, value in entry.metadata - if key in PRE_SUBMISSION_RESULT_METADATA_KEYS - and type(value) is int - and value >= 0 - ), - ) - for entry in self.entries - ), - ) - - -@dataclass(frozen=True, slots=True) -class DefaultPreSubmissionExecutionInput: - """Exact 04A/04B1 facts required by the default executor.""" - - plan: EffectivePreSubmissionExecutionPlan - commitment: ArtifactCommitment - inspection: SubmissionArchiveInspectionResult - manifest: SubmissionManifest - change_gate: SubmissionChangeGateResult - packet: SubmissionPacketView - prepared_generation_id: UUID - storage_scheme: str - - -class EffectivePreSubmissionProcessor: - """Prepared-artifact processor owning the sole effective-plan dispatch.""" - - def __init__( - self, - *, - archive_inspector: SubmissionArchiveInspector, - catalogue: PreSubmissionCheckerCatalogue, - execution_input: DefaultPreSubmissionExecutionInput, - ) -> None: - """Bind one exact inspector, catalogue, and immutable execution input.""" - self._archive_inspector = archive_inspector - self._catalogue = catalogue - self._input = execution_input - self._aborted = threading.Event() - - def abort(self) -> None: - """Prevent checker callback access after caller cancellation or timeout.""" - self._aborted.set() - - def process_blocking(self, reader: BinaryIO, workspace: Path) -> PreSubmissionExecutionResult: - """Project and execute inside preparation's bounded blocking adapter.""" - self._validate_input() - return self._archive_inspector.project_and_run( - reader, - workspace, - expected=self._input.inspection, - callback=self._execute_unless_aborted, - ) - - def _execute_unless_aborted( - self, - tree: SealedSubmissionTree, - ) -> PreSubmissionExecutionResult: - """Deny checker access after caller cancellation or deadline expiry.""" - if self._aborted.is_set(): - raise DefaultPreSubmissionExecutionError("pre_submission_execution_aborted") - return self._execute(tree) - - def _validate_input(self) -> None: - """Fail closed unless every plan, archive, and manifest fact agrees.""" - plan = self._input.plan - if canonical_json_hash(plan.as_dict()) != plan.plan_sha256: - raise PreSubmissionInfrastructureUnavailable("pre_submission_plan_identity_invalid") - if plan.catalogue_manifest_sha256 != self._catalogue.manifest_sha256: - raise PreSubmissionInfrastructureUnavailable("pre_submission_catalogue_stale") - if build_submission_manifest(self._input.inspection) != self._input.manifest: - raise PreSubmissionInfrastructureUnavailable("submission_manifest_identity_unavailable") - if ( - self._input.change_gate.archive_sha256 != self._input.commitment.sha256 - or self._input.change_gate.archive_byte_count != self._input.commitment.byte_count - or self._input.change_gate.manifest != self._input.manifest - ): - raise PreSubmissionInfrastructureUnavailable("submission_change_identity_invalid") - - def _execute(self, tree: SealedSubmissionTree) -> PreSubmissionExecutionResult: - """Run the complete ordered platform-plus-project plan exactly once.""" - results: list[PreSubmissionEntryResult] = [] - statuses: dict[str, PreSubmissionResultStatus] = {} - blocked = False - executed_ids = { - entry.definition_id - for entry in self._input.plan.entries - if entry.phase in _EXECUTED_PHASES | {PreSubmissionCheckerPhase.PROJECT_POLICY.value} - } - for entry in self._input.plan.entries: - definition = self._catalogue.definition(entry.definition_id) - if entry.definition_id in statuses: - raise PreSubmissionInfrastructureUnavailable("pre_submission_duplicate_result") - if ( - entry.checker_definition_state - == PreSubmissionCheckerState.DISABLED.value - and definition.classification.mandatory - ): - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_infrastructure_unavailable" - ) - if ( - entry.definition_version != definition.version - or entry.dispatch_capability != definition.dispatch_capability - or entry.classification != definition.classification.value - or entry.checker_definition_state != definition.state.value - ): - raise PreSubmissionInfrastructureUnavailable("pre_submission_plan_entry_stale") - if any(dependency not in executed_ids for dependency in entry.dependencies): - raise PreSubmissionInfrastructureUnavailable("pre_submission_dependency_invalid") - unmet = any( - statuses.get(dependency) - not in { - PreSubmissionResultStatus.PASSED, - PreSubmissionResultStatus.WARNING, - PreSubmissionResultStatus.ADVISORY_DISABLED, - } - for dependency in entry.dependencies - ) - if blocked or unmet: - result = self._result( - entry, - PreSubmissionResultStatus.DEPENDENCY_NOT_RUN, - message_code="dependency_not_run", - ) - elif ( - entry.checker_definition_state - == PreSubmissionCheckerState.DISABLED.value - ): - result = self._result( - entry, - PreSubmissionResultStatus.ADVISORY_DISABLED, - message_code="advisory_disabled", - ) - else: - result = self._dispatch(entry, tree) - if result.status is PreSubmissionResultStatus.FAILED: - blocked = True - statuses[entry.definition_id] = result.status - results.append(result) - if set(statuses) != executed_ids: - raise PreSubmissionInfrastructureUnavailable("pre_submission_result_incomplete") - execution = PreSubmissionExecutionResult( - plan_sha256=self._input.plan.plan_sha256, - custody=PreSubmissionExecutionCustody( - prepared_generation_id=self._input.prepared_generation_id, - archive_sha256=self._input.commitment.sha256, - archive_byte_count=self._input.commitment.byte_count, - semantic_manifest_sha256=self._input.manifest.sha256, - storage_scheme=self._input.storage_scheme, - ), - eligible=not blocked, - entries=tuple(results), - ) - validate_pre_submission_execution_result(self._input.plan, execution) - return execution - - def _dispatch( - self, - entry: EffectivePreSubmissionPlanEntry, - tree: SealedSubmissionTree, - ) -> PreSubmissionEntryResult: - """Dispatch one catalogue-validated platform capability or policy primitive.""" - if entry.dispatch_kind == "policy_primitive": - return self._dispatch_policy(entry, tree) - if entry.dispatch_kind != "platform_capability": - raise PreSubmissionInfrastructureUnavailable("pre_submission_dispatch_kind_invalid") - try: - capability = PreSubmissionPlatformCapability(entry.dispatch_capability) - except ValueError as exc: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_dispatch_capability_unknown" - ) from exc - if entry.phase in { - PreSubmissionCheckerPhase.CUSTODY.value, - PreSubmissionCheckerPhase.IDENTITY.value, - }: - return self._result(entry, PreSubmissionResultStatus.PASSED) - if entry.phase == PreSubmissionCheckerPhase.MATERIALIZATION.value: - if tree.entries != self._input.manifest.entries: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_materialization_unavailable" - ) - return self._result( - entry, - PreSubmissionResultStatus.PASSED, - metadata=(("entry_count", len(tree.entries)),), - ) - if capability is PreSubmissionPlatformCapability.SUBMISSION_PACKET: - missing = int(not self._input.packet.summary.strip()) + int( - not self._input.packet.contributor_attestation.strip() - ) - return self._blocking_or_pass(entry, missing, "submission_packet_invalid") - if capability is PreSubmissionPlatformCapability.ATTESTATION: - facts = attestation_validation_facts(self._input.packet.contributor_attestation) - missing = sum( - not getattr(facts, key) - for key in ( - "has_required_length", - "has_non_generic_text", - "has_confidentiality_term", - "has_credential_term", - "has_source_or_platform_term", - ) - ) - return self._blocking_or_pass(entry, missing, "attestation_missing") - if capability is PreSubmissionPlatformCapability.SENSITIVE_PATH: - matches = sum( - _is_high_confidence_sensitive(item.normalized_path) for item in tree.entries - ) - return self._blocking_or_pass(entry, matches, "sensitive_path_forbidden") - if capability is not PreSubmissionPlatformCapability.QUALITY_WARNING: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_dispatch_capability_unknown" - ) - matches = matched_low_quality_patterns( - ( - self._input.packet.summary, - self._input.packet.contributor_attestation, - *(item.normalized_path for item in tree.entries), - ) - ) - if matches: - return self._result( - entry, - PreSubmissionResultStatus.WARNING, - message_code="quality_signal_warning", - metadata=(("matched_category_count", len(matches)),), - ) - return self._result(entry, PreSubmissionResultStatus.PASSED) - - def _dispatch_policy( - self, entry: EffectivePreSubmissionPlanEntry, tree: SealedSubmissionTree - ) -> PreSubmissionEntryResult: - """Evaluate one closed project-policy primitive using server-owned facts only.""" - try: - primitive = PreSubmissionPolicyPrimitive(entry.dispatch_capability) - except ValueError as exc: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_primitive_unknown" - ) from exc - config = entry.configuration.as_dict() - file_entries = tuple(item for item in tree.entries if item.sha256 is not None) - paths = {item.normalized_path for item in file_entries} - failure_count = 0 - message_code = "passed" - if primitive is PreSubmissionPolicyPrimitive.REQUIRE_FILE: - required = self._canonical_policy_paths(config.get("artifact_paths")) - failure_count = sum(path not in paths for path in required) - message_code = "required_file_missing" - elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_MINIMUM_EVIDENCE: - required = self._canonical_policy_paths(config.get("evidence_paths")) - failure_count = sum(path not in paths for path in required) - message_code = "required_evidence_missing" - elif primitive is PreSubmissionPolicyPrimitive.FORBID_ARTIFACT: - patterns = self._string_list(config.get("patterns")) - failure_count = sum( - any(fnmatchcase(path, pattern) for pattern in patterns) for path in paths - ) - message_code = "forbidden_artifact_present" - elif primitive is PreSubmissionPolicyPrimitive.LIMIT_FILE_SIZE: - maximum = self._positive_limit(config, "maximum_file_size_bytes") - failure_count = sum(item.byte_count > maximum for item in file_entries) - message_code = "file_size_limit_exceeded" - elif primitive is PreSubmissionPolicyPrimitive.LIMIT_PACKAGE_SIZE: - maximum = self._positive_limit(config, "maximum_package_size_bytes") - failure_count = int(self._input.manifest.total_expanded_bytes > maximum) - message_code = "package_size_limit_exceeded" - elif primitive is PreSubmissionPolicyPrimitive.LIMIT_ARCHIVE_SIZE: - maximum = self._positive_limit(config, "maximum_archive_size_bytes") - if maximum == 0: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_configuration_invalid" - ) - failure_count = int(self._input.commitment.byte_count > maximum) - message_code = "archive_size_limit_exceeded" - elif primitive is PreSubmissionPolicyPrimitive.LIMIT_ARCHIVE_ENTRIES: - maximum = self._positive_limit(config, "maximum_archive_entries") - if maximum == 0: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_configuration_invalid" - ) - failure_count = int(self._input.manifest.entry_count > maximum) - message_code = "archive_entry_limit_exceeded" - elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_ATTESTATION: - terms = self._string_list(config.get("terms")) - attestation = self._input.packet.contributor_attestation.casefold() - failure_count = sum(term.casefold() not in attestation for term in terms) - message_code = "policy_attestation_missing" - elif primitive is PreSubmissionPolicyPrimitive.VALIDATE_SUBMISSION_PACKET: - fields = set(self._string_list(config.get("fields"))) - # The artifact hash manifest is derived and verified before dispatch. - known = {"summary", "worker_attestation", "contributor_attestation", "artifact_hash_manifest"} - if not fields.issubset(known): - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_field_unmappable" - ) - failure_count = int("summary" in fields and not self._input.packet.summary.strip()) - failure_count += int( - bool(fields & {"worker_attestation", "contributor_attestation"}) - and not self._input.packet.contributor_attestation.strip() - ) - message_code = "submission_packet_invalid" - elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_MANIFEST_FIELD: - failure_count = 0 - elif primitive is PreSubmissionPolicyPrimitive.VERIFY_HASH: - if config.get("algorithm") != "sha256": - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_hash_algorithm_invalid" - ) - elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_PACKAGING: - allowed = config.get("allowed_package_formats", []) - if not isinstance(allowed, list) or any(not isinstance(item, str) for item in allowed): - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_configuration_invalid" - ) - failure_count = int(bool(allowed) and "zip" not in allowed) - message_code = "packaging_requirement_failed" - elif primitive is PreSubmissionPolicyPrimitive.ENFORCE_STORAGE_SCHEME: - schemes = self._string_list(config.get("schemes")) - failure_count = int(self._input.storage_scheme not in schemes) - message_code = "storage_scheme_not_allowed" - elif primitive is PreSubmissionPolicyPrimitive.WARN_LOW_QUALITY_GENERATED_ARTIFACT: - matches = matched_low_quality_patterns( - (self._input.packet.summary, self._input.packet.contributor_attestation, *paths) - ) - return self._result( - entry, - PreSubmissionResultStatus.WARNING if matches else PreSubmissionResultStatus.PASSED, - message_code="quality_signal_warning" if matches else "passed", - metadata=(("matched_category_count", len(matches)),) if matches else (), - ) - else: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_primitive_unknown" - ) - return self._blocking_or_pass(entry, failure_count, message_code) - - @staticmethod - def _string_list(value: object) -> tuple[str, ...]: - if not isinstance(value, list) or any( - not isinstance(item, str) or not item for item in value - ): - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_configuration_invalid" - ) - return tuple(value) - - @classmethod - def _canonical_policy_paths(cls, value: object) -> tuple[str, ...]: - paths = cls._string_list(value) - if len(paths) != len(set(paths)) or any( - not is_canonical_relative_path(path) for path in paths - ): - raise PreSubmissionInfrastructureUnavailable("pre_submission_policy_path_unmappable") - return paths - - @staticmethod - def _positive_limit(config: dict[str, object], key: str) -> int: - value = config.get(key) - if type(value) is not int or value < 0: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_policy_configuration_invalid" - ) - return value - - def _blocking_or_pass( - self, - entry: EffectivePreSubmissionPlanEntry, - finding_count: int, - message_code: str, - ) -> PreSubmissionEntryResult: - """Build one blocking failure or successful bounded result.""" - return self._result( - entry, - ( - PreSubmissionResultStatus.FAILED - if finding_count - else PreSubmissionResultStatus.PASSED - ), - failure_code=entry.failure_code if finding_count else None, - message_code=message_code if finding_count else "passed", - metadata=(("finding_count", finding_count),) if finding_count else (), - ) - - def _result( - self, - entry: EffectivePreSubmissionPlanEntry, - status: PreSubmissionResultStatus, - *, - failure_code: str | None = None, - message_code: str = "passed", - metadata: tuple[tuple[str, int], ...] = (), - ) -> PreSubmissionEntryResult: - """Build one result bound to the exact plan and definition version.""" - return PreSubmissionEntryResult( - schema_version=entry.result_schema, - definition=PreSubmissionResultDefinition( - dispatch_authority="workstream.pre_submission_checker_catalogue", - definition_id=entry.definition_id, - definition_version=entry.definition_version, - public_name=entry.public_name, - source=entry.policy_trace_source, - ), - policy_trace=PreSubmissionResultPolicyTrace( - effective_plan_sha256=self._input.plan.plan_sha256, - rule_instance_id=entry.rule_instance_id, - locked_policy_sha256=self._input.plan.lineage.effective_policy_hash, - ), - phase=entry.phase, - order=entry.order, - classification=entry.classification, - severity="warning" if entry.classification == "advisory" else "blocking", - status=status, - failure_code=failure_code, - message_code=message_code, - metadata=metadata, - ) - - -def validate_pre_submission_execution_result( - plan: EffectivePreSubmissionExecutionPlan, - execution: PreSubmissionExecutionResult, -) -> None: - """Reject any result envelope not produced by the exact immutable plan.""" - custody = execution.custody - try: - ArtifactCommitment.validate_sha256(custody.archive_sha256) - ArtifactCommitment.validate_sha256(custody.semantic_manifest_sha256) - except ValueError as exc: - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_result_context_invalid" - ) from exc - if ( - type(custody.prepared_generation_id) is not UUID - or type(custody.archive_byte_count) is not int - or type(execution.eligible) is not bool - or custody.archive_byte_count < 0 - or custody.storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES - or execution.plan_sha256 != plan.plan_sha256 - or len(execution.entries) != len(plan.entries) - ): - raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") - for plan_entry, result in zip(plan.entries, execution.entries, strict=True): - metadata = result.metadata - if type(metadata) is not tuple or any( - type(item) is not tuple - or len(item) != 2 - or type(item[0]) is not str - or type(item[1]) is not int - for item in metadata - ): - raise PreSubmissionInfrastructureUnavailable( - "pre_submission_result_context_invalid" - ) - metadata_keys = tuple(item[0] for item in metadata) - if ( - type(result.status) is not PreSubmissionResultStatus - or result.schema_version != plan_entry.result_schema - or len(metadata_keys) != len(set(metadata_keys)) - or any( - key not in PRE_SUBMISSION_RESULT_METADATA_KEYS - or value < 0 - for key, value in metadata - ) - ): - raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") - try: - validate_pre_submission_execution_facts(plan, execution.bounded_facts()) - except PreSubmissionInfrastructureUnavailableError as exc: - raise PreSubmissionInfrastructureUnavailable(str(exc)) from exc - - -def _is_high_confidence_sensitive(normalized_path: str) -> bool: - """Match only the narrow Workstream-default sensitive-path set.""" - parts = tuple(part.casefold() for part in PurePosixPath(normalized_path).parts) - if not parts: - return False - return ( - parts[-1] in _FORBIDDEN_EXACT_NAMES - or parts[-1].endswith(_FORBIDDEN_SUFFIXES) - or any(part in _FORBIDDEN_DIRECTORY_NAMES for part in parts) - ) diff --git a/backend/app/modules/checkers/runner.py b/backend/app/modules/checkers/runner.py deleted file mode 100644 index 5d330a3ef..000000000 --- a/backend/app/modules/checkers/runner.py +++ /dev/null @@ -1,844 +0,0 @@ -"""Checker registry, structural checkers, and manifest hashing.""" - -from __future__ import annotations - -import asyncio - -import hashlib -import json -import re -from fnmatch import fnmatchcase -from collections.abc import Callable, Sequence -from dataclasses import dataclass, field -from typing import Awaitable -from typing import Protocol - -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitDefinition, PostSubmitPolicyEntry, structural_definition, -) -from app.modules.checkers.api.post_submit import ( - ExpectedPostSubmitContext, ObservedPostSubmitContext, -) -from app.modules.checkers.pre_submit_defaults import ( - LOW_QUALITY_GENERATED_PATTERNS, - attestation_validation_facts, - matched_low_quality_patterns, - normalize_policy_token as _normalize_policy_token, -) - -CHECKER_STATUS_PASSED = "passed" -CHECKER_STATUS_WARNING = "warning" -CHECKER_STATUS_FAILED = "failed" - -CHECKER_SEVERITY_INFO = "info" -CHECKER_SEVERITY_LOW = "low" -CHECKER_SEVERITY_MEDIUM = "medium" -CHECKER_SEVERITY_HIGH = "high" -CHECKER_SEVERITY_CRITICAL = "critical" - -BLOCKING_SEVERITIES = {CHECKER_SEVERITY_HIGH, CHECKER_SEVERITY_CRITICAL} -ROUTING_TASK_SETUP_BLOCKED = "task_setup_blocked" - -HASH_TOKEN_PATTERN = re.compile(r"^sha256:\S+$") -FORBIDDEN_EXACT_SEGMENTS = { - ".env", - ".git", - "credential", - "id_rsa", - "node_modules", - "private_key", - "secret", - "token", -} -FORBIDDEN_SUFFIXES = (".pem", ".key") - - -class CheckerNameConflict(ValueError): - """Raised when duplicate checker names are registered.""" - - -class UnknownChecker(ValueError): - """Raised when a policy references an unregistered checker.""" - - -class ArtifactManifestError(ValueError): - """Raised when an artifact manifest cannot be canonicalized.""" - - -@dataclass(frozen=True) -class CheckerOutcome: - """In-memory checker result before persistence.""" - - checker_name: str - status: str - severity: str - message: str - blocks_review: bool = False - worker_message: str | None = None - worker_suggested_fix: str | None = None - worker_evidence_refs: list[str] = field(default_factory=list) - worker_visible: bool = True - metadata: dict = field(default_factory=dict) - routing_recommendation: str | None = None - - -class _StructuralTask(Protocol): - """Criteria consumed by the shared structural handlers.""" - - @property - def acceptance_criteria(self) -> str | None: - """Read acceptance criteria.""" - - -class _StructuralEvidence(Protocol): - """Read-only evidence attributes used by registered structural handlers.""" - - @property - def label(self) -> str: - """Read label.""" - - @property - def uri(self) -> str | None: - """Read uri.""" - - @property - def hash(self) -> str | None: - """Read hash.""" - - @property - def type(self) -> str: - """Read type.""" - - @property - def metadata_json(self) -> dict: - """Read metadata json.""" - - -class _StructuralSubmission(Protocol): - """Packet attributes consumed by the structural handlers.""" - - @property - def summary(self) -> str: - """Read summary.""" - - @property - def package_hash(self) -> str | None: - """Read package hash.""" - - @property - def worker_attestation(self) -> str: - """Read worker attestation.""" - - @property - def artifact_hash_manifest(self) -> list[dict]: - """Read artifact hash manifest.""" - - @property - def evidence_items(self) -> Sequence[_StructuralEvidence]: - """Read evidence items.""" - - -@dataclass(frozen=True) -class CheckerContext: - """Data available to structural checkers.""" - - task: _StructuralTask - submission: _StructuralSubmission - required_checker_names: frozenset[str] - warning_checker_names: frozenset[str] - blocking_severities: frozenset[str] - effective_policy: dict | None = None - observed_context: ObservedPostSubmitContext | None = None - expected_context: ExpectedPostSubmitContext | None = None - - -class Checker(Protocol): - """Protocol implemented by registered checkers.""" - - name: str - - async def run(self, context: CheckerContext) -> CheckerOutcome: - """Run the checker against a finalized submission context.""" - - -CheckerHandler = Callable[[CheckerContext], Awaitable[CheckerOutcome]] - - -class FunctionChecker: - """Checker adapter around a plain callable.""" - - def __init__(self, name: str, handler: "CheckerHandler") -> None: - """Create a named checker. - - Args: - name: Canonical checker name referenced by checker policies. - handler: Function that evaluates a checker context. - """ - self.name = name - self._handler = handler - - async def run(self, context: CheckerContext) -> CheckerOutcome: - """Run the wrapped checker handler.""" - return await self._handler(context) - - -@dataclass(frozen=True) -class CheckerRegistration: - """Keep canonical metadata and the actual implementation in one entry.""" - - checker: Checker - definition: PostSubmitDefinition - - -class CheckerRegistry: - """Install exactly one handler and immutable definition per checker ID.""" - - def __init__(self) -> None: - """Start an empty explicit installation.""" - self._checkers: dict[str, CheckerRegistration] = {} - - def register(self, checker: Checker, *, definition: PostSubmitDefinition) -> None: - """Reject duplicate IDs and metadata belonging to another handler.""" - if checker.name in self._checkers: - raise CheckerNameConflict(f"checker already registered: {checker.name}") - definition = PostSubmitDefinition.model_validate(definition) - if definition.capability_id != checker.name: - raise ValueError("checker registration definition mismatch") - self._checkers[checker.name] = CheckerRegistration(checker, definition) - - def resolve(self, checker_name: str) -> CheckerRegistration: - """Resolve the sole installed handler for an exact checker ID.""" - try: - return self._checkers[checker_name] - except KeyError as exc: - raise UnknownChecker(f"unregistered checker: {checker_name}") from exc - - def definitions(self) -> tuple[PostSubmitDefinition, ...]: - """Return metadata attached to installed handlers.""" - return tuple(entry.definition for entry in self._checkers.values()) - - def require_registered(self, checker_names: set[str]) -> None: - """Reject selections absent from this installation.""" - missing = sorted(checker_names.difference(self.names())) - if missing: - raise UnknownChecker(f"unregistered checker policy names: {', '.join(missing)}") - - async def run( - self, context: CheckerContext, entries: tuple[PostSubmitPolicyEntry, ...] - ) -> list[CheckerOutcome]: - """Validate all compiled member identities before executing any handler.""" - registrations = [] - seen: set[str] = set() - for entry in entries: - entry = PostSubmitPolicyEntry.model_validate(entry) - registration = self.resolve(entry.checker_id) - definition = registration.definition - if entry.checker_id in seen or ( - entry.definition_version != definition.capability_version - or entry.implementation_version != definition.implementation_version - ): - raise ValueError("post-submit installed implementation mismatch") - definition.validate_configuration(entry.configuration) - seen.add(entry.checker_id) - registrations.append(registration) - outcomes = [] - for registration in registrations: - async with asyncio.timeout(registration.definition.resources.deadline_ms / 1000): - outcomes.append(await registration.checker.run(context)) - return outcomes - - def names(self) -> set[str]: - """Return installed checker IDs.""" - return set(self._checkers) - - -def canonical_artifact_manifest_hash(manifest: list[dict]) -> str: - """Hash a submission artifact manifest using Workstream canonical JSON. - - Args: - manifest: Artifact hash entries persisted on a submission. - - Returns: - SHA-256 digest prefixed with ``sha256:``. - - Raises: - ArtifactManifestError: If duplicate artifacts or invalid entries are present. - """ - seen_artifacts: set[str] = set() - canonical_entries: list[dict] = [] - for entry in manifest: - artifact = str(entry.get("artifact", "")).strip() - artifact_hash = str(entry.get("hash", "")).strip() - if not artifact or not artifact_hash: - raise ArtifactManifestError("artifact manifest entries require artifact and hash") - if artifact in seen_artifacts: - raise ArtifactManifestError(f"duplicate artifact in manifest: {artifact}") - seen_artifacts.add(artifact) - canonical_entries.append( - { - "artifact": artifact, - "hash": artifact_hash, - "notes": entry.get("notes"), - "size_bytes": entry.get("size_bytes"), - } - ) - encoded = json.dumps( - sorted(canonical_entries, key=lambda item: (item["artifact"], item["hash"])), - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - ).encode("utf-8") - return f"sha256:{hashlib.sha256(encoded).hexdigest()}" - - -def _pass(name: str, message: str, *, metadata: dict | None = None) -> CheckerOutcome: - """Build a passing checker outcome.""" - return CheckerOutcome( - checker_name=name, - status=CHECKER_STATUS_PASSED, - severity=CHECKER_SEVERITY_INFO, - message=message, - worker_message=message, - metadata=metadata or {}, - ) - - -def _fail( - name: str, - message: str, - suggested_fix: str, - *, - metadata: dict | None = None, - worker_visible: bool = True, - routing_recommendation: str | None = None, -) -> CheckerOutcome: - """Build a worker-fixable failing checker outcome.""" - return CheckerOutcome( - checker_name=name, - status=CHECKER_STATUS_FAILED, - severity=CHECKER_SEVERITY_HIGH, - message=message, - blocks_review=True, - worker_message=message if worker_visible else None, - worker_suggested_fix=suggested_fix if worker_visible else None, - worker_visible=worker_visible, - metadata=metadata or {}, - routing_recommendation=routing_recommendation, - ) - - -def _warning(name: str, message: str, *, metadata: dict | None = None) -> CheckerOutcome: - """Build a non-blocking warning checker outcome.""" - return CheckerOutcome( - checker_name=name, - status=CHECKER_STATUS_WARNING, - severity=CHECKER_SEVERITY_MEDIUM, - message=message, - blocks_review=False, - worker_message=message, - worker_visible=True, - metadata=metadata or {}, - ) - - -def _packet_shape_outcome(summary: str, package_hash: str, manifest: list[dict]) -> CheckerOutcome: - """Validate required packet fields for persisted post-submit checks.""" - missing: list[str] = [] - if not summary.strip(): - missing.append("summary") - if not package_hash.strip(): - missing.append("package_hash") - if not manifest: - missing.append("artifact_hash_manifest") - if missing: - return _fail( - "check_submission_packet", - f"Submission packet is missing required fields: {', '.join(missing)}.", - "Add the missing packet fields before submitting.", - metadata={"missing_fields": missing}, - ) - return _pass("check_submission_packet", "Submission packet contains required fields.") - - -def _hash_token_is_valid(value: str | None) -> bool: - """Return whether a hash value has the Chunk 8 structural token shape.""" - if value is None: - return False - return bool(HASH_TOKEN_PATTERN.match(value.strip())) - - -def _normalize_artifact_path(value: str) -> str: - """Normalize an artifact path for checker comparison. - - Raises: - ValueError: If the path is empty, absolute, or contains traversal segments. - """ - normalized = re.sub(r"/+", "/", value.strip().replace("\\", "/")) - if normalized.startswith("./"): - normalized = normalized[2:] - if not normalized or normalized.startswith("/"): - raise ValueError("artifact path must be relative and non-empty") - segments = normalized.split("/") - if any(segment in {"", ".", ".."} for segment in segments): - raise ValueError("artifact path must not contain empty or traversal segments") - return normalized - - -def _evidence_integrity_outcome(manifest: list[dict], evidence_items: list[dict]) -> CheckerOutcome: - """Validate manifest and evidence reference structural integrity.""" - try: - artifact_names = [] - invalid_artifacts = [] - for entry in manifest: - artifact = str(entry.get("artifact", "")) - artifact_names.append(_normalize_artifact_path(artifact)) - if not _hash_token_is_valid(str(entry.get("hash", ""))): - invalid_artifacts.append(artifact) - manifest_hash = canonical_artifact_manifest_hash(manifest) - except (ArtifactManifestError, ValueError) as exc: - return _fail( - "check_evidence_integrity", - "Artifact manifest contains invalid or duplicate entries.", - "Use relative artifact paths, one unique artifact entry per artifact, and a sha256 hash token.", - metadata={"integrity_error": str(exc)}, - ) - if invalid_artifacts: - return _fail( - "check_evidence_integrity", - "Artifact manifest contains malformed hash references.", - "Use hash values shaped like sha256: for each artifact.", - metadata={"invalid_artifact_count": len(invalid_artifacts)}, - ) - - missing_evidence_hash_labels = [ - str(item.get("label", "")).strip() or str(index) - for index, item in enumerate(evidence_items, start=1) - if item.get("uri") and not _hash_token_is_valid(item.get("hash")) - ] - if missing_evidence_hash_labels: - return _fail( - "check_evidence_integrity", - "Evidence object references are missing structural hash tokens.", - "Add sha256: hash values for evidence objects stored in local, R2, or S3 storage.", - metadata={"missing_evidence_hash_count": len(missing_evidence_hash_labels)}, - ) - return _pass( - "check_evidence_integrity", - "Artifact manifest and evidence references are structurally valid.", - metadata={"artifact_manifest_hash": manifest_hash, "artifact_count": len(artifact_names)}, - ) - - -def _evidence_candidate_tokens(evidence_item: dict) -> set[str]: - """Return policy tokens exposed by one submitted evidence item.""" - candidates = { - str(evidence_item.get("type", "")), - str(evidence_item.get("label", "")), - } - metadata = evidence_item.get("metadata") or evidence_item.get("metadata_json") or {} - if isinstance(metadata, dict): - for key in ("key", "policy_key", "evidence_key", "required_evidence_key"): - value = metadata.get(key) - if value is not None: - candidates.add(str(value)) - return { - normalized for candidate in candidates if (normalized := _normalize_policy_token(candidate)) - } - - -def _evidence_presence_outcome( - evidence_items: list[dict], - required_evidence: list[str], -) -> CheckerOutcome: - """Validate that required evidence keys are represented by evidence rows.""" - required_tokens = { - _normalize_policy_token(required_key): required_key - for required_key in required_evidence - if _normalize_policy_token(required_key) - } - provided_tokens: set[str] = set() - for item in evidence_items: - provided_tokens.update(_evidence_candidate_tokens(item)) - - missing = [ - original_key - for token, original_key in sorted(required_tokens.items()) - if token not in provided_tokens - ] - if missing: - return _fail( - "check_evidence_present", - "Submission is missing required evidence references.", - "Attach evidence items required by the task before submitting.", - metadata={"missing_required_evidence": missing}, - ) - return _pass("check_evidence_present", "Submission includes required evidence references.") - - -def _required_files_outcome(required_files: list[str], manifest: list[dict]) -> CheckerOutcome: - """Validate that required files are represented in the artifact manifest.""" - if not required_files: - return _pass("check_required_files", "Task does not require specific files.") - try: - required = {_normalize_artifact_path(path) for path in required_files} - provided = { - _normalize_artifact_path(str(entry.get("artifact", ""))) - for entry in manifest - if str(entry.get("artifact", "")).strip() - } - except ValueError as exc: - return _fail( - "check_required_files", - str(exc), - "Use relative artifact paths without traversal segments.", - ) - missing = sorted(required.difference(provided)) - if missing: - return _fail( - "check_required_files", - "Submission is missing required artifact files.", - "Add every file required by the task to the artifact hash manifest.", - metadata={"missing_required_files": missing}, - ) - return _pass("check_required_files", "Submission includes required artifact files.") - - -def _forbidden_files_outcome( - manifest: list[dict], - evidence_items: list[dict], - forbidden_patterns: list[str] | None = None, -) -> CheckerOutcome: - """Validate artifact and evidence paths against default forbidden path patterns.""" - paths = [str(entry.get("artifact", "")) for entry in manifest] - paths.extend(str(item.get("uri", "")) for item in evidence_items if item.get("uri")) - forbidden_categories: set[str] = set() - patterns = forbidden_patterns or [] - for path in paths: - try: - normalized = _normalize_artifact_path(path) - except ValueError: - continue - lowered_segments = [segment.lower() for segment in normalized.split("/")] - filename = lowered_segments[-1] - if any(segment in FORBIDDEN_EXACT_SEGMENTS for segment in lowered_segments): - forbidden_categories.add("forbidden_path_segment") - if filename.endswith(FORBIDDEN_SUFFIXES): - forbidden_categories.add("forbidden_file_suffix") - if any(_path_matches_forbidden_pattern(normalized, pattern) for pattern in patterns): - forbidden_categories.add("forbidden_policy_pattern") - if forbidden_categories: - return _fail( - "check_forbidden_files", - "Submission includes forbidden artifact or evidence path patterns.", - "Remove forbidden files or evidence references before review.", - metadata={"forbidden_categories": sorted(forbidden_categories)}, - ) - return _pass("check_forbidden_files", "Submission does not include default forbidden paths.") - - -def _required_artifact_paths(effective_policy: dict | None) -> list[str]: - """Return required artifact paths from the locked effective project policy.""" - if effective_policy is None: - return [] - return [ - str(artifact["path"]) - for artifact in effective_policy.get("required_artifacts", []) - if artifact.get("required", True) - ] - - -def _required_evidence_keys(effective_policy: dict | None) -> list[str]: - """Return required evidence keys from the locked effective project policy.""" - if effective_policy is None: - return [] - return [ - str(evidence["key"]) - for evidence in effective_policy.get("required_evidence", []) - if evidence.get("required", True) - ] - - -def _forbidden_artifact_patterns(effective_policy: dict | None) -> list[str]: - """Return forbidden artifact patterns from the locked effective project policy.""" - if effective_policy is None: - return [] - return [ - str(rule["pattern"]) - for rule in effective_policy.get("forbidden_artifacts", []) - if rule.get("pattern") - ] - - -def _required_attestation_terms(effective_policy: dict | None) -> list[str]: - """Return required attestation terms from the locked effective project policy.""" - if effective_policy is None: - return [] - return [str(term) for term in effective_policy.get("attestation_terms", []) if term] - - -def _path_matches_forbidden_pattern(path: str, pattern: str) -> bool: - """Return whether a normalized path matches a forbidden policy pattern.""" - normalized = path.lower() - normalized_pattern = pattern.strip().lower() - if not normalized_pattern: - return False - filename = normalized.rsplit("/", 1)[-1] - segments = normalized.split("/") - return ( - fnmatchcase(normalized, normalized_pattern) - or fnmatchcase(filename, normalized_pattern) - or any(fnmatchcase(segment, normalized_pattern) for segment in segments) - ) - - -def _missing_effective_policy_outcome(checker_name: str) -> CheckerOutcome: - """Build a setup-blocked outcome when locked effective policy is missing.""" - return _fail( - checker_name, - "Task is missing locked effective project submission artifact policy context.", - "A project manager must re-screen the task before review can continue.", - worker_visible=False, - routing_recommendation=ROUTING_TASK_SETUP_BLOCKED, - ) - - -def _confidentiality_attestation_outcome( - worker_attestation: str, - required_terms: list[str] | None = None, -) -> CheckerOutcome: - """Validate confidentiality and project-required attestation terms.""" - facts = attestation_validation_facts( - worker_attestation, - required_terms=required_terms or (), - ) - missing_terms = facts.missing_attestation_terms - if not all( - [ - facts.has_required_length, - facts.has_non_generic_text, - facts.has_confidentiality_term, - facts.has_credential_term, - facts.has_source_or_platform_term, - not missing_terms, - ] - ): - return _fail( - "check_confidentiality_attestation", - "Submission is missing the required confidentiality attestation.", - ( - "Attest that the submission excludes confidential data, credentials, " - "and copied source or platform artifacts." - ), - metadata={ - "has_required_length": facts.has_required_length, - "has_confidentiality_term": facts.has_confidentiality_term, - "has_credential_term": facts.has_credential_term, - "has_source_or_platform_term": facts.has_source_or_platform_term, - "missing_attestation_terms": missing_terms, - }, - ) - return _pass( - "check_confidentiality_attestation", - "Submission includes the required confidentiality attestation.", - ) - - -def _low_quality_generated_artifacts_outcome( - summary: str, - worker_attestation: str, - manifest: list[dict], - evidence_items: list[dict], -) -> CheckerOutcome | None: - """Warn on obvious placeholder or generated-output signals.""" - haystack_parts = [summary, worker_attestation] - haystack_parts.extend(str(entry.get("artifact", "")) for entry in manifest) - haystack_parts.extend(str(entry.get("notes", "")) for entry in manifest) - haystack_parts.extend(str(item.get("label", "")) for item in evidence_items) - matched = matched_low_quality_patterns(haystack_parts) - if not matched: - return None - return _warning( - "check_low_quality_generated_artifacts", - "Submission contains placeholder or generic generated-output signals.", - metadata={ - "matched_pattern_count": len(matched), - "matched_categories": [ - pattern for pattern in LOW_QUALITY_GENERATED_PATTERNS if pattern in matched - ], - }, - ) - - -async def check_submission_packet(context: CheckerContext) -> CheckerOutcome: - """Validate required submission packet fields after the packet is locked.""" - return _packet_shape_outcome( - context.submission.summary, - context.submission.package_hash or "", - context.submission.artifact_hash_manifest, - ) - - -def policy_context_mismatches( - expected: ExpectedPostSubmitContext, observed: ObservedPostSubmitContext, -) -> list[str]: - """Compare the one closed current fact shape for owner guards and checking.""" - expected = ExpectedPostSubmitContext.model_validate(expected) - observed = ObservedPostSubmitContext.model_validate(observed) - return [key for key, value in expected.model_dump().items() if getattr(observed, key) != value] - - -async def check_policy_context_present(context: CheckerContext) -> CheckerOutcome: - """Compare current locked facts without asserting ownership or authorization.""" - if context.observed_context is None or context.expected_context is None: - raise ValueError("checker requires expected and observed policy context") - mismatches = policy_context_mismatches(context.expected_context, context.observed_context) - if mismatches: - return _fail( - "check_policy_context_present", - "Submission policy context is incomplete or inconsistent.", - "A project manager must correct the locked policy context.", - metadata={"invalid_context": mismatches}, - worker_visible=False, - routing_recommendation=ROUTING_TASK_SETUP_BLOCKED, - ) - return _pass("check_policy_context_present", "Submission has consistent policy context.") - - -async def check_evidence_integrity(context: CheckerContext) -> CheckerOutcome: - """Validate artifact manifest and evidence reference structural integrity.""" - evidence_items = [ - { - "label": item.label, - "uri": item.uri, - "hash": item.hash, - "type": item.type, - } - for item in context.submission.evidence_items - ] - return _evidence_integrity_outcome(context.submission.artifact_hash_manifest, evidence_items) - - -async def check_evidence_present(context: CheckerContext) -> CheckerOutcome: - """Validate that evidence references exist when the task requires evidence.""" - if context.effective_policy is None: - return _missing_effective_policy_outcome("check_evidence_present") - required_evidence = _required_evidence_keys(context.effective_policy) - evidence_items = [ - { - "label": item.label, - "uri": item.uri, - "hash": item.hash, - "type": item.type, - "metadata_json": item.metadata_json, - } - for item in context.submission.evidence_items - ] - return _evidence_presence_outcome( - evidence_items, - required_evidence, - ) - - -async def check_acceptance_criteria_present(context: CheckerContext) -> CheckerOutcome: - """Validate that task setup includes reviewable acceptance criteria.""" - if not (context.task.acceptance_criteria or "").strip(): - return _fail( - "check_acceptance_criteria_present", - "Task is not review-ready because acceptance criteria are missing.", - "A project manager must add reviewable acceptance criteria before review can continue.", - worker_visible=False, - routing_recommendation=ROUTING_TASK_SETUP_BLOCKED, - ) - return _pass( - "check_acceptance_criteria_present", - "Task includes reviewable acceptance criteria.", - ) - - -async def check_required_files(context: CheckerContext) -> CheckerOutcome: - """Validate required task files against the artifact manifest.""" - if context.effective_policy is None: - return _missing_effective_policy_outcome("check_required_files") - required_files = _required_artifact_paths(context.effective_policy) - return _required_files_outcome( - required_files, - context.submission.artifact_hash_manifest, - ) - - -async def check_forbidden_files(context: CheckerContext) -> CheckerOutcome: - """Validate artifact and evidence paths against forbidden path patterns.""" - evidence_items = [ - { - "label": item.label, - "uri": item.uri, - "hash": item.hash, - "type": item.type, - } - for item in context.submission.evidence_items - ] - if context.effective_policy is None: - return _missing_effective_policy_outcome("check_forbidden_files") - forbidden_patterns = _forbidden_artifact_patterns(context.effective_policy) - return _forbidden_files_outcome( - context.submission.artifact_hash_manifest, - evidence_items, - forbidden_patterns, - ) - - -async def check_confidentiality_attestation(context: CheckerContext) -> CheckerOutcome: - """Validate the worker confidentiality attestation.""" - if context.effective_policy is None: - return _missing_effective_policy_outcome("check_confidentiality_attestation") - return _confidentiality_attestation_outcome( - context.submission.worker_attestation, - _required_attestation_terms(context.effective_policy), - ) - - -async def check_low_quality_generated_artifacts(context: CheckerContext) -> CheckerOutcome: - """Warn on obvious placeholder or generated-output signals.""" - evidence_items = [ - { - "label": item.label, - "uri": item.uri, - "hash": item.hash, - "type": item.type, - } - for item in context.submission.evidence_items - ] - outcome = _low_quality_generated_artifacts_outcome( - context.submission.summary, - context.submission.worker_attestation, - context.submission.artifact_hash_manifest, - evidence_items, - ) - return outcome or _pass( - "check_low_quality_generated_artifacts", - "Submission does not contain obvious generated-output placeholder signals.", - ) - - -def default_checker_registry() -> CheckerRegistry: - """Install one structural implementation per canonical definition.""" - from app.modules.checkers.post_submit_catalogue import build_post_submit_catalogue - - registry = CheckerRegistry() - for handler in ( - check_submission_packet, - check_policy_context_present, - check_evidence_present, - check_evidence_integrity, - check_required_files, - check_forbidden_files, - check_confidentiality_attestation, - check_low_quality_generated_artifacts, - check_acceptance_criteria_present, - ): - registry.register( - FunctionChecker(handler.__name__, handler), - definition=structural_definition(handler.__name__), - ) - build_post_submit_catalogue(registry) - return registry diff --git a/backend/app/modules/checkers/selected_post_submit_execution.py b/backend/app/modules/checkers/selected_post_submit_execution.py new file mode 100644 index 000000000..4700703fd --- /dev/null +++ b/backend/app/modules/checkers/selected_post_submit_execution.py @@ -0,0 +1,113 @@ +"""Selected external POST execution inside one verified ART callback.""" + +from __future__ import annotations + +from dataclasses import asdict + +from app.interfaces.external_checker_execution import ExternalCheckerExecutionAdapter +from app.interfaces.external_services import ( + ExternalServiceProtocolError, + ExternalServiceUnavailableError, +) +from app.modules.checkers.api.external import ( + PostSubmitExternalCheckerIdentity, + external_checker_json_hash, + make_external_checker_execution_request, +) +from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy +from app.modules.checkers.api.selected_post_submit import ( + ExternalPostSubmitIsolationFacts, + ExternalPostSubmitMemberFacts, + SelectedPostSubmitExecutionFacts, + SelectedPostSubmitInfrastructureUnavailable, +) + + +def _input_body(*, requirement_id, identity, material, entries) -> dict[str, object]: + """Build one normalized POST input from actual Submission and ART custody.""" + return { + "schema_version": "workstream.external_checker_post_submit_input.v1", + "requirement_id": requirement_id, + "project_id": str(identity.project_id), + "task_id": str(identity.task_id), + "assignment_id": str(identity.assignment_id), + "submission_id": str(identity.submission_id), + "submission_version": identity.submission_version, + "content_sha256": material.content_sha256, + "byte_count": material.byte_count, + "semantic_manifest_sha256": material.semantic_manifest_sha256, + "manifest": [asdict(item) for item in entries], + } + + +class SelectedPostSubmitProcessor: + """Run every locked POST image and return evidence without acceptance authority.""" + + def __init__( + self, + *, + policy: LockedExternalPostSubmitPolicy, + identity: PostSubmitExternalCheckerIdentity, + execution: ExternalCheckerExecutionAdapter, + ) -> None: + self._policy = policy + self._identity = identity + self._execution = execution + + async def evaluate(self, _request, material_view) -> SelectedPostSubmitExecutionFacts: + """Execute in policy order while ART owns actual verified POST material.""" + material = material_view.external_checker_material + members: list[ExternalPostSubmitMemberFacts] = [] + try: + for order, binding in enumerate(self._policy.entries): + input_value = _input_body( + requirement_id=binding.requirement_id, + identity=self._identity, + material=material, + entries=material_view.entries, + ) + request = make_external_checker_execution_request( + registry=binding.registry, + identity=self._identity, + configuration=binding.configuration, + configuration_sha256=binding.configuration_sha256, + input=input_value, + input_sha256=external_checker_json_hash(input_value), + materials=(material,), + ) + with material_view.external_checker_grant(request) as grant: + response = await self._execution.execute(request, grant) + result = response.result + if result.outcome == "infrastructure_failed": + raise SelectedPostSubmitInfrastructureUnavailable( + result.infrastructure_failure_code or "invalid_output" + ) + if response.isolation is None: + raise SelectedPostSubmitInfrastructureUnavailable("invalid_output") + members.append( + ExternalPostSubmitMemberFacts( + order=order, + requirement_id=binding.requirement_id, + registry_entry_id=binding.registry.registry_entry_id, + registry_entry_digest=binding.registry.entry_digest, + request_digest=request.request_digest, + result=result, + isolation=ExternalPostSubmitIsolationFacts( + **asdict(response.isolation) + ), + ) + ) + except ExternalServiceUnavailableError as exc: + raise SelectedPostSubmitInfrastructureUnavailable( + "implementation_unavailable" + ) from exc + except ExternalServiceProtocolError as exc: + raise SelectedPostSubmitInfrastructureUnavailable("invalid_output") from exc + return SelectedPostSubmitExecutionFacts( + policy_sha256=self._policy.policy_sha256, + required_external_count=len(self._policy.entries), + external_members=tuple(members), + ) + + +__all__ = ("SelectedPostSubmitProcessor",) diff --git a/backend/app/modules/checkers/selected_pre_submit_coordination.py b/backend/app/modules/checkers/selected_pre_submit_coordination.py new file mode 100644 index 000000000..ff373bb54 --- /dev/null +++ b/backend/app/modules/checkers/selected_pre_submit_coordination.py @@ -0,0 +1,514 @@ +"""Durable selected PRE reservation and TASK-first lease/finalization custody.""" + +from __future__ import annotations + +import threading +from dataclasses import replace +from datetime import timedelta +from typing import Protocol +from uuid import UUID + +from sqlalchemy import func, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.identifiers import new_record_id +from app.modules.checkers.api.selected_pre_submit import ( + PreparedSelectedPreSubmitExecution, + PreparedSelectedPreSubmitFinalization, + SelectedPreSubmitCompletion, + SelectedPreSubmitCurrentFacts, + SelectedPreSubmitExecuteFacts, + SelectedPreSubmitExecutionFacts, + SelectedPreSubmitFinalizeFacts, + SelectedPreSubmitLeaseFacts, + SelectedPreSubmitReservationFacts, + SelectedPreSubmitRequestConflict, + SelectedPreSubmitTerminalFacts, + SelectedPreSubmitUnavailable, +) +from app.modules.checkers.models import SelectedPreSubmitRun + + +class SelectedPreSubmitTaskGuard(Protocol): + """TASK owns current contributor/assignment/policy checks before CHECKERS locks.""" + + async def lock_selected_pre_submit_scope( + self, reservation: SelectedPreSubmitReservationFacts + ) -> None: + """Lock and validate exact current TASK scope through caller commit.""" + + +_CAPABILITY_SEAL = object() + + +class SelectedPreSubmitAdmissionCapability: + """One-use completed-pass authority minted inside the finalization root.""" + + __slots__ = ( + "_consumed", + "_owner", + "_seal", + "_lock", + "run_id", + "terminal_digest", + "attempt_id", + "prepared_generation_id", + "archive_sha256", + "semantic_manifest_sha256", + ) + + def __init__(self, *_: object, **__: object) -> None: + raise TypeError("selected PRE admission capabilities are coordinator-issued") + + @classmethod + def _issued( + cls, + *, + owner: SelectedPreSubmitCoordinator, + run_id: UUID, + terminal: SelectedPreSubmitTerminalFacts, + ) -> SelectedPreSubmitAdmissionCapability: + reservation = terminal.lease.reservation + value = object.__new__(cls) + value._owner = owner + value._seal = _CAPABILITY_SEAL + value._lock = threading.Lock() + value._consumed = False + value.run_id = run_id + value.terminal_digest = terminal.terminal_digest + value.attempt_id = reservation.attempt_id + value.prepared_generation_id = reservation.prepared_generation_id + value.archive_sha256 = reservation.archive_sha256 + value.semantic_manifest_sha256 = reservation.semantic_manifest_sha256 + return value + + def consume( + self, + *, + run_id: UUID, + terminal_digest: str, + attempt_id: UUID, + prepared_generation_id: UUID, + archive_sha256: str, + semantic_manifest_sha256: str, + ) -> UUID: + """Consume only for the exact finalized run and still-live prepared bytes.""" + with self._lock: + if ( + self._seal is not _CAPABILITY_SEAL + or self._consumed + or self._owner._capability is not self + or ( + run_id, + terminal_digest, + attempt_id, + prepared_generation_id, + archive_sha256, + semantic_manifest_sha256, + ) + != ( + self.run_id, + self.terminal_digest, + self.attempt_id, + self.prepared_generation_id, + self.archive_sha256, + self.semantic_manifest_sha256, + ) + ): + raise SelectedPreSubmitUnavailable( + "selected_pre_submit_admission_unavailable" + ) + self._consumed = True + self._owner._capability = None + return self.run_id + + +class SelectedPreSubmitExecutionCapability: + """One-use authority for the committed lease's process-local ART callback.""" + + __slots__ = ("_consumed", "_owner", "_seal", "_lock", "run_id", "lease") + + def __init__(self, *_: object, **__: object) -> None: + raise TypeError("selected PRE execution capabilities are coordinator-issued") + + @classmethod + def _issued( + cls, + *, + owner: SelectedPreSubmitCoordinator, + run_id: UUID, + lease: SelectedPreSubmitLeaseFacts, + ) -> SelectedPreSubmitExecutionCapability: + value = object.__new__(cls) + value._owner = owner + value._seal = _CAPABILITY_SEAL + value._lock = threading.Lock() + value._consumed = False + value.run_id = run_id + value.lease = lease + return value + + def consume( + self, + *, + run_id: UUID, + lease: SelectedPreSubmitLeaseFacts, + ) -> SelectedPreSubmitLeaseFacts: + """Consume only for the exact committed run generation and request facts.""" + with self._lock: + if ( + self._seal is not _CAPABILITY_SEAL + or self._consumed + or self._owner._execution_capability is not self + or run_id != self.run_id + or lease != self.lease + ): + raise SelectedPreSubmitUnavailable( + "selected_pre_submit_execution_unavailable" + ) + self._consumed = True + self._owner._execution_capability = None + return self.lease + + +def _require_root(session: AsyncSession) -> None: + transaction = session.sync_session.get_transaction() + if ( + transaction is None + or not transaction.is_active + or session.in_nested_transaction() + ): + raise RuntimeError("selected PRE coordination requires one root transaction") + + +def _matches(row: SelectedPreSubmitRun, facts: SelectedPreSubmitReservationFacts) -> bool: + return ( + row.request_json == facts.canonical_body() + and row.request_digest == facts.request_digest + and row.id == str(facts.attempt_id) + and row.actor_profile_id == str(facts.actor_profile_id) + and row.identity_link_id == str(facts.identity_link_id) + and row.project_id == str(facts.project_id) + and row.task_id == str(facts.task_id) + and row.assignment_id == str(facts.assignment_id) + and row.idempotency_key == str(facts.idempotency_key) + and row.prepared_generation_id == str(facts.prepared_generation_id) + and row.policy_sha256 == facts.policy_sha256 + and row.default_behavior_sha256 == facts.default_behavior_sha256 + and row.default_input_sha256 == facts.default_input_sha256 + and row.archive_sha256 == facts.archive_sha256 + and row.archive_byte_count == facts.archive_byte_count + and row.semantic_manifest_sha256 == facts.semantic_manifest_sha256 + and row.execution_budget_ms == facts.execution_budget_ms + ) + + +class SelectedPreSubmitCoordinator: + """Reserve, lease and finalize one selected PRE run in caller-owned roots.""" + + def __init__( + self, + session: AsyncSession, + *, + tasks: SelectedPreSubmitTaskGuard, + ) -> None: + self._session = session + self._tasks = tasks + self._capability: SelectedPreSubmitAdmissionCapability | None = None + self._execution_capability: SelectedPreSubmitExecutionCapability | None = None + + async def read_current( + self, facts: SelectedPreSubmitReservationFacts + ) -> SelectedPreSubmitCurrentFacts | None: + """Lock TASK before reading and validating one actor/key CHECKERS row.""" + _require_root(self._session) + if type(facts) is not SelectedPreSubmitReservationFacts: + raise TypeError("selected PRE reservation facts are invalid") + await self._tasks.lock_selected_pre_submit_scope(facts) + row = await self._session.scalar( + select(SelectedPreSubmitRun) + .where( + SelectedPreSubmitRun.actor_profile_id + == str(facts.actor_profile_id), + SelectedPreSubmitRun.idempotency_key + == str(facts.idempotency_key), + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is None: + return None + try: + stored = SelectedPreSubmitReservationFacts.from_canonical_body( + row.request_json + ) + normalized = replace( + facts, + attempt_id=stored.attempt_id, + prepared_generation_id=stored.prepared_generation_id, + ) + terminal = ( + SelectedPreSubmitTerminalFacts.from_canonical_body(row.terminal_json) + if row.terminal_json is not None + else None + ) + except (TypeError, ValueError): + raise SelectedPreSubmitUnavailable( + "selected_pre_submit_stored_facts_invalid" + ) from None + if ( + normalized != stored + or not _matches(row, stored) + or row.request_digest != stored.request_digest + or ( + terminal is not None + and ( + terminal.terminal_digest != row.terminal_digest + or terminal.lease.reservation != stored + or terminal.execution is not None + and terminal.execution.eligible != row.eligible + ) + ) + ): + raise SelectedPreSubmitRequestConflict( + "selected_pre_submit_request_conflict" + ) + return SelectedPreSubmitCurrentFacts( + run_id=UUID(row.id), + reservation=stored, + status=row.status, + terminal=terminal, + ) + + async def reserve(self, facts: SelectedPreSubmitReservationFacts) -> UUID: + """Acquire TASK custody before the CHECKERS key and exact request row.""" + _require_root(self._session) + if type(facts) is not SelectedPreSubmitReservationFacts: + raise TypeError("selected PRE reservation facts are invalid") + await self._tasks.lock_selected_pre_submit_scope(facts) + await self._session.execute( + text("select pg_advisory_xact_lock(hashtextextended(:key,0))"), + { + "key": ( + "checkers:selected-pre:" + + str(facts.actor_profile_id) + + ":" + + str(facts.idempotency_key) + ) + }, + ) + row = await self._session.scalar( + select(SelectedPreSubmitRun) + .where( + SelectedPreSubmitRun.actor_profile_id + == str(facts.actor_profile_id), + SelectedPreSubmitRun.idempotency_key + == str(facts.idempotency_key), + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is not None: + if not _matches(row, facts): + raise SelectedPreSubmitUnavailable("selected_pre_submit_request_conflict") + return UUID(row.id) + row = SelectedPreSubmitRun( + id=str(facts.attempt_id), + actor_profile_id=str(facts.actor_profile_id), + identity_link_id=str(facts.identity_link_id), + project_id=str(facts.project_id), + task_id=str(facts.task_id), + assignment_id=str(facts.assignment_id), + idempotency_key=str(facts.idempotency_key), + prepared_generation_id=str(facts.prepared_generation_id), + request_json=facts.canonical_body(), + request_digest=facts.request_digest, + policy_sha256=facts.policy_sha256, + default_behavior_sha256=facts.default_behavior_sha256, + default_input_sha256=facts.default_input_sha256, + archive_sha256=facts.archive_sha256, + archive_byte_count=facts.archive_byte_count, + semantic_manifest_sha256=facts.semantic_manifest_sha256, + execution_budget_ms=facts.execution_budget_ms, + status="queued", + lease_generation=0, + ) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError: + raise SelectedPreSubmitUnavailable("selected_pre_submit_request_conflict") from None + return facts.attempt_id + + async def claim( + self, + *, + run_id: UUID, + facts: SelectedPreSubmitReservationFacts, + authority: PreparedSelectedPreSubmitExecution, + ) -> SelectedPreSubmitLeaseFacts: + """Issue one database-timed lease after fresh TASK and execute authority.""" + row = await self._lock(run_id=run_id, facts=facts) + if row.status != "queued": + raise SelectedPreSubmitUnavailable("selected_pre_submit_lease_unavailable") + now = await self._session.scalar(select(func.clock_timestamp())) + assert now is not None + lease = SelectedPreSubmitLeaseFacts( + reservation=facts, + lease_id=new_record_id(), + lease_generation=row.lease_generation + 1, + lease_expires_at=now + timedelta(milliseconds=facts.execution_budget_ms), + ) + execute_facts = SelectedPreSubmitExecuteFacts(lease) + evidence = await authority.consume(execute_facts) + if evidence.facts_digest != execute_facts.facts_digest: + raise SelectedPreSubmitUnavailable("selected_pre_submit_execute_evidence_invalid") + row.lease_id = str(lease.lease_id) + row.lease_generation = lease.lease_generation + row.lease_expires_at = lease.lease_expires_at + row.execute_evidence_id = str(evidence.evidence_id) + row.started_at = now + row.status = "running" + await self._session.flush() + return lease + + async def committed_execution( + self, + *, + run_id: UUID, + lease: SelectedPreSubmitLeaseFacts, + ) -> SelectedPreSubmitExecutionCapability: + """Mint execution authority only after a fresh root observes the committed lease.""" + if self._session.in_transaction(): + raise RuntimeError("selected PRE execution requires a committed lease") + async with self._session.begin(): + row = await self._lock(run_id=run_id, facts=lease.reservation) + if ( + row.status != "running" + or row.lease_id != str(lease.lease_id) + or row.lease_generation != lease.lease_generation + or row.lease_expires_at != lease.lease_expires_at + ): + raise SelectedPreSubmitUnavailable( + "selected_pre_submit_execution_unavailable" + ) + capability = SelectedPreSubmitExecutionCapability._issued( + owner=self, + run_id=run_id, + lease=lease, + ) + self._execution_capability = capability + return capability + + async def read_expired_lease( + self, *, run_id: UUID, facts: SelectedPreSubmitReservationFacts + ) -> SelectedPreSubmitLeaseFacts: + """Discover an expired lease without reconstructing its ART capability.""" + row = await self._lock(run_id=run_id, facts=facts) + now = await self._session.scalar(select(func.clock_timestamp())) + if ( + now is None + or row.status != "running" + or row.lease_id is None + or row.lease_expires_at is None + or row.lease_expires_at >= now + ): + raise SelectedPreSubmitUnavailable( + "selected_pre_submit_expired_lease_unavailable" + ) + return SelectedPreSubmitLeaseFacts( + reservation=facts, + lease_id=UUID(row.lease_id), + lease_generation=row.lease_generation, + lease_expires_at=row.lease_expires_at, + ) + + async def finalize( + self, + *, + run_id: UUID, + lease: SelectedPreSubmitLeaseFacts, + authority: PreparedSelectedPreSubmitFinalization, + execution: SelectedPreSubmitExecutionFacts | None, + infrastructure_failure_code: str | None, + ) -> SelectedPreSubmitCompletion: + """Fence the lease and persist one exact terminal value under fresh TASK custody.""" + facts = lease.reservation + row = await self._lock(run_id=run_id, facts=facts) + if ( + row.status != "running" + or row.lease_id != str(lease.lease_id) + or row.lease_generation != lease.lease_generation + or row.lease_expires_at != lease.lease_expires_at + ): + raise SelectedPreSubmitUnavailable("selected_pre_submit_finalization_unavailable") + completed_at = await self._session.scalar(select(func.clock_timestamp())) + assert completed_at is not None and row.started_at is not None + if execution is not None and completed_at > lease.lease_expires_at: + raise SelectedPreSubmitUnavailable("selected_pre_submit_lease_expired") + outcome = "completed" if execution is not None else "infrastructure_failed" + finalize_facts = SelectedPreSubmitFinalizeFacts( + lease=lease, + execution=execution, + infrastructure_failure_code=infrastructure_failure_code, + ) + evidence = await authority.consume(finalize_facts) + if evidence.facts_digest != finalize_facts.facts_digest: + raise SelectedPreSubmitUnavailable("selected_pre_submit_finalize_evidence_invalid") + terminal = SelectedPreSubmitTerminalFacts( + lease=lease, + outcome=outcome, + execution=execution, + infrastructure_failure_code=infrastructure_failure_code, + queued_at=row.queued_at, + started_at=row.started_at, + completed_at=completed_at, + ) + row.status = outcome + row.terminal_json = terminal.canonical_body() + row.terminal_digest = terminal.terminal_digest + row.eligible = execution.eligible if execution is not None else None + row.finalize_evidence_id = str(evidence.evidence_id) + row.completed_at = completed_at + await self._session.flush() + capability = ( + SelectedPreSubmitAdmissionCapability._issued( + owner=self, run_id=run_id, terminal=terminal + ) + if execution is not None and execution.eligible + else None + ) + self._capability = capability + return SelectedPreSubmitCompletion(terminal=terminal, capability=capability) + + async def _lock( + self, *, run_id: UUID, facts: SelectedPreSubmitReservationFacts + ) -> SelectedPreSubmitRun: + _require_root(self._session) + if not isinstance(run_id, UUID) or type(facts) is not SelectedPreSubmitReservationFacts: + raise SelectedPreSubmitUnavailable("selected_pre_submit_run_unavailable") + await self._tasks.lock_selected_pre_submit_scope(facts) + row = await self._session.scalar( + select(SelectedPreSubmitRun) + .where( + SelectedPreSubmitRun.id == str(run_id), + SelectedPreSubmitRun.project_id == str(facts.project_id), + SelectedPreSubmitRun.task_id == str(facts.task_id), + SelectedPreSubmitRun.assignment_id == str(facts.assignment_id), + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is None or not _matches(row, facts): + raise SelectedPreSubmitUnavailable("selected_pre_submit_run_unavailable") + return row + + +__all__ = ( + "SelectedPreSubmitAdmissionCapability", + "SelectedPreSubmitCompletion", + "SelectedPreSubmitCoordinator", + "SelectedPreSubmitExecutionCapability", + "SelectedPreSubmitTaskGuard", +) diff --git a/backend/app/modules/checkers/selected_pre_submit_execution.py b/backend/app/modules/checkers/selected_pre_submit_execution.py new file mode 100644 index 000000000..a79f9d16d --- /dev/null +++ b/backend/app/modules/checkers/selected_pre_submit_execution.py @@ -0,0 +1,227 @@ +"""Selected default-plus-external PRE execution over one ART callback.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict + +from app.core.cancellation import await_cancellation_resistant +from app.interfaces.artifacts import ArtifactScratchIntegrityError +from app.interfaces.external_checker_execution import ExternalCheckerExecutionAdapter +from app.interfaces.external_services import ( + ExternalServiceProtocolError, + ExternalServiceUnavailableError, +) +from app.modules.checkers.api.default_checker import WorkstreamDefaultCheckerInput +from app.modules.checkers.api.external import ( + PreSubmitExternalCheckerIdentity, + PreSubmitExternalCheckerMaterial, + external_checker_json_hash, + make_external_checker_execution_request, +) +from app.modules.checkers.api.external_policy import LockedExternalPreSubmitPolicy +from app.modules.checkers.api.materialization import SubmissionMaterialEntry +from app.modules.checkers.api.selected_pre_submit import ( + ExternalPreSubmitIsolationFacts, + ExternalPreSubmitMemberFacts, + SelectedPreSubmitExecutionFacts, + SelectedPreSubmitInfrastructureUnavailable, +) +from app.modules.checkers.default_checker import ( + WorkstreamDefaultCheckerUnavailable, + evaluate_verified_archive, +) + + +class _PreSubmitMaterialView: + """Keep verified reads and the opaque PRE grant inside one ART callback.""" + + def __init__(self, tree, *, grants, workspace) -> None: + self._tree, self._grants, self._workspace = tree, grants, workspace + self._closed = False + + def close(self) -> None: + self._closed = True + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError("pre-submit material view is closed") + + @property + def entries(self) -> tuple[SubmissionMaterialEntry, ...]: + self._require_open() + return tuple( + SubmissionMaterialEntry( + item.normalized_path, + item.entry_type.value, + item.byte_count, + item.sha256, + item.executable, + ) + for item in self._tree.entries + ) + + def read_file(self, normalized_path: str, *, maximum_bytes: int) -> bytes: + self._require_open() + return self._tree.read_file(normalized_path, maximum_bytes=maximum_bytes) + + def external_checker_grant(self, request): + self._require_open() + return self._grants.issue(request, self._workspace) + + +def _input_body( + *, + requirement_id: str, + identity: PreSubmitExternalCheckerIdentity, + material: PreSubmitExternalCheckerMaterial, + value: WorkstreamDefaultCheckerInput, +) -> dict[str, object]: + """Build the one normalized public PRE image input from verified facts.""" + return { + "schema_version": "workstream.external_checker_pre_submit_input.v1", + "requirement_id": requirement_id, + "project_id": str(identity.project_id), + "task_id": str(identity.task_id), + "assignment_id": str(identity.assignment_id), + "archive_sha256": material.archive_sha256, + "archive_byte_count": material.archive_byte_count, + "semantic_manifest_sha256": material.semantic_manifest_sha256, + "manifest": [asdict(item) for item in value.entries], + "packet": { + "summary": value.packet.summary, + "contributor_attestation": value.packet.contributor_attestation, + }, + } + + +class SelectedPreSubmitProcessor: + """Run the default and every required locked external PRE image in order.""" + + def __init__( + self, + *, + archive_inspector, + inspection, + policy: LockedExternalPreSubmitPolicy, + default_input: WorkstreamDefaultCheckerInput, + identity: PreSubmitExternalCheckerIdentity, + material: PreSubmitExternalCheckerMaterial, + grants, + execution: ExternalCheckerExecutionAdapter, + ) -> None: + self._inspector, self._inspection = archive_inspector, inspection + self._policy, self._default_input = policy, default_input + self._identity, self._material = identity, material + self._grants, self._execution = grants, execution + self._aborted = False + self._active: asyncio.Task | None = None + self._view: _PreSubmitMaterialView | None = None + + def abort(self) -> None: + """Revoke callback access and cancel the active default/image invocation.""" + self._aborted = True + if self._view is not None: + self._view.close() + if self._active is not None: + self._active.cancel() + + async def process(self, reader, workspace) -> SelectedPreSubmitExecutionFacts: + """Project once, execute outside DB, and drain ART cleanup before return.""" + projection = self._inspector._projected_tree( + reader, workspace, expected=self._inspection + ) + tree = await asyncio.to_thread(projection.__enter__) + try: + if self._aborted: + raise asyncio.CancelledError + self._view = _PreSubmitMaterialView( + tree, grants=self._grants, workspace=workspace + ) + self._active = asyncio.create_task( + asyncio.to_thread( + evaluate_verified_archive, self._default_input, self._view + ) + ) + default = await self._active + members: list[ExternalPreSubmitMemberFacts] = [] + if default.eligible: + for order, binding in enumerate(self._policy.entries): + if self._aborted: + raise asyncio.CancelledError + input_value = _input_body( + requirement_id=binding.requirement_id, + identity=self._identity, + material=self._material, + value=self._default_input, + ) + request = make_external_checker_execution_request( + registry=binding.registry, + identity=self._identity, + configuration=binding.configuration, + configuration_sha256=binding.configuration_sha256, + input=input_value, + input_sha256=external_checker_json_hash(input_value), + materials=(self._material,), + ) + with self._view.external_checker_grant(request) as grant: + self._active = asyncio.create_task( + self._execution.execute(request, grant) + ) + response = await self._active + result = response.result + if result.outcome == "infrastructure_failed": + raise SelectedPreSubmitInfrastructureUnavailable( + result.infrastructure_failure_code or "invalid_output" + ) + if response.isolation is None: + raise SelectedPreSubmitInfrastructureUnavailable("invalid_output") + member = ExternalPreSubmitMemberFacts( + order=order, + requirement_id=binding.requirement_id, + registry_entry_id=binding.registry.registry_entry_id, + registry_entry_digest=binding.registry.entry_digest, + request_digest=request.request_digest, + result=result, + isolation=ExternalPreSubmitIsolationFacts( + **asdict(response.isolation) + ), + ) + members.append(member) + if result.verdict == "failed": + break + eligible = default.eligible and len(members) == len(self._policy.entries) and all( + item.result.verdict == "passed" for item in members + ) + return SelectedPreSubmitExecutionFacts( + policy_sha256=self._policy.policy_sha256, + workstream_default=default, + required_external_count=len(self._policy.entries), + external_members=tuple(members), + eligible=eligible, + ) + except WorkstreamDefaultCheckerUnavailable as exc: + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") from exc + except ExternalServiceUnavailableError as exc: + raise SelectedPreSubmitInfrastructureUnavailable( + "implementation_unavailable" + ) from exc + except ExternalServiceProtocolError as exc: + raise SelectedPreSubmitInfrastructureUnavailable("invalid_output") from exc + finally: + if self._view is not None: + self._view.close() + self._active = None + try: + await await_cancellation_resistant( + asyncio.to_thread(projection.__exit__, None, None, None) + ) + except ArtifactScratchIntegrityError: + raise + except Exception: + raise ArtifactScratchIntegrityError( + "pre_submit_projection_cleanup_unconfirmed" + ) from None + + +__all__ = ("SelectedPreSubmitProcessor",) diff --git a/backend/app/modules/outbox/delivery.py b/backend/app/modules/outbox/delivery.py index 43001db51..dec991060 100644 --- a/backend/app/modules/outbox/delivery.py +++ b/backend/app/modules/outbox/delivery.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import asynccontextmanager from datetime import datetime, timedelta import json +from types import MappingProxyType from uuid import UUID, uuid4 from sqlalchemy.exc import SQLAlchemyError @@ -152,14 +153,25 @@ def __init__( authorization_factory: Callable[[AsyncSession], OutboxDispatchAuthorizationPort], registry: HandlerRegistry, options: DeliveryOptions, + event_options: Mapping[tuple[str, int], DeliveryOptions] | None = None, ) -> None: """Require explicit AUTH composition and bounded handler registration.""" self._sessions = session_factory self._authorization = authorization_factory self._registry = registry self._options = DeliveryOptions.model_validate(options.model_dump()) + overrides = {} + for key, value in (event_options or {}).items(): + if key not in registry.keys or type(value) is not DeliveryOptions: + raise ValueError("outbox event delivery options are invalid") + overrides[key] = DeliveryOptions.model_validate(value.model_dump()) + self._event_options = MappingProxyType(overrides) self._running_handlers: set[asyncio.Task] = set() + def _options_for(self, event_type: str, event_version: int) -> DeliveryOptions: + """Resolve one registered event's immutable limits or the shared defaults.""" + return self._event_options.get((event_type, event_version), self._options) + @asynccontextmanager async def _authority(self, session, **selectors): """Conceal AUTH boundary failures without swallowing owner errors.""" @@ -195,12 +207,17 @@ async def claim(self, event_id: UUID, project_id: UUID, owner: str) -> OutboxCla async with self._sessions() as session: event = await DeliveryRepository(session).event(event_id, project_id) now = await database_time(session) + options = ( + self._options_for(event.event_type, event.event_version) + if event is not None + else self._options + ) if ( event is None or self._registry.get(event.event_type, event.event_version) is None or event.delivery_state not in ("pending", "retryable") or event.next_attempt_at > now - or event.claim_generation >= self._options.max_attempts + or event.claim_generation >= options.max_attempts ): return None claim = OutboxClaim( @@ -210,7 +227,7 @@ async def claim(self, event_id: UUID, project_id: UUID, owner: str) -> OutboxCla claim_generation=event.claim_generation + 1, claim_owner=owner, claimed_at=now, - claim_expires_at=now + timedelta(seconds=self._options.lease_seconds), + claim_expires_at=now + timedelta(seconds=options.lease_seconds), ) facts = _facts(claim, OutboxDispatchPhase.CLAIM) async with self._sessions() as session, session.begin(): @@ -309,15 +326,16 @@ async def invoke(self, claim: OutboxClaim) -> DeliveryReceipt | None: return None annotate_current_span(outbox_correlation_id=envelope.correlation_id) handler = self._registry.get(envelope.event_type, envelope.event_version) + options = self._options_for(envelope.event_type, envelope.event_version) cause = FinalizationCause.UNKNOWN task = None try: loop = asyncio.get_running_loop() - deadline = loop.time() + self._options.handler_timeout_seconds + deadline = loop.time() + options.handler_timeout_seconds task = asyncio.create_task(handler(envelope)) self._running_handlers.add(task) task.add_done_callback(self._handler_done) - done, _ = await asyncio.wait((task,), timeout=self._options.handler_timeout_seconds) + done, _ = await asyncio.wait((task,), timeout=options.handler_timeout_seconds) if done and loop.time() < deadline and not task.cancelled(): result = task.result() if type(result) is HandlerOutcome: @@ -335,15 +353,18 @@ async def finalize(self, claim: OutboxClaim, cause: FinalizationCause) -> Delive """Authorize the exact outcome; response loss replays original delivery facts.""" claim = _checked_claim(claim) async with self._sessions() as session: - attempt = await DeliveryRepository(session).attempt(claim) - if attempt is None or claim_from_attempt(attempt) != claim: + repository = DeliveryRepository(session) + event = await repository.event(claim.event_id, claim.project_id) + attempt = await repository.attempt(claim) + if event is None or attempt is None or claim_from_attempt(attempt) != claim: raise DeliveryUnavailable("outbox_delivery_unavailable") + options = self._options_for(event.event_type, event.event_version) now = await database_time(session) if attempt.stage == "completed": now = datetime.fromisoformat( json.loads(attempt.outcome_json)["receipt_completed_at"] ) - value = _outcome(claim, attempt.invoked_at, cause, now, self._options) + value = _outcome(claim, attempt.invoked_at, cause, now, options) proposed = DeliveryReceipt( claim=claim, outcome_json=_encode(value), outcome_digest=canonical_json_hash(value) ) @@ -356,7 +377,7 @@ async def finalize(self, claim: OutboxClaim, cause: FinalizationCause) -> Delive attempt.invoked_at, cause, datetime.fromisoformat(stored["receipt_completed_at"]), - self._options, + options, ) if _encode(expected) != completed.receipt.outcome_json: raise DeliveryUnavailable("outbox_delivery_unavailable") from None @@ -382,6 +403,7 @@ async def _finalize( now = await database_time(session) if event is None or attempt is None or claim_from_attempt(attempt) != claim: raise DeliveryUnavailable("outbox_delivery_unavailable") + options = self._options_for(event.event_type, event.event_version) if attempt.stage == "completed": if _receipt(claim, attempt) != receipt: raise _CompletedDuringFinalization(_receipt(claim, attempt)) @@ -397,7 +419,7 @@ async def _finalize( raise DeliveryUnavailable("outbox_delivery_unavailable") value = json.loads(receipt.outcome_json) completed = datetime.fromisoformat(value["receipt_completed_at"]) - expected = _outcome(claim, attempt.invoked_at, cause, completed, self._options) + expected = _outcome(claim, attempt.invoked_at, cause, completed, options) if ( completed > now or _encode(expected) != receipt.outcome_json diff --git a/backend/app/modules/projects/api/guide_proposals.py b/backend/app/modules/projects/api/guide_proposals.py index ee359426b..d5266ec0b 100644 --- a/backend/app/modules/projects/api/guide_proposals.py +++ b/backend/app/modules/projects/api/guide_proposals.py @@ -183,6 +183,15 @@ def require_record_ids(self): return self +class MissingCheckerCapability(BaseModel): + """Bounded manager feedback from the approved compilation's missing checks.""" + + model_config = ConfigDict(extra="forbid", frozen=True, revalidate_instances="always") + requirement_id: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,99}$", strict=True) + stage: Literal["pre_submit", "post_submit"] + title: str = Field(min_length=1, max_length=1000, strict=True) + + class GuideProposalError(RuntimeError): """Bounded failure; the caller must roll back its root transaction.""" @@ -196,36 +205,55 @@ def __init__( "operation_conflict", "storage_unavailable", ], + *, + missing_capabilities: tuple[MissingCheckerCapability, ...] = (), ) -> None: + if ( + type(missing_capabilities) is not tuple + or len(missing_capabilities) > 200 + or (missing_capabilities and code != "approval_blocked") + ): + raise ValueError("invalid missing capability refusal") + self.missing_capabilities = tuple( + MissingCheckerCapability.model_validate(item) for item in missing_capabilities + ) self.code = code super().__init__(code) - - # Composition binds external capabilities; the public PROJECTS contract imports no other owner. _ActorT = TypeVar("_ActorT", contravariant=True) _MaterialT = TypeVar("_MaterialT", contravariant=True) -_PreCapabilitiesT = TypeVar("_PreCapabilitiesT", contravariant=True) -_PostCapabilitiesT = TypeVar("_PostCapabilitiesT", contravariant=True) -_PlannerT = TypeVar("_PlannerT", contravariant=True) +_CapabilitiesT = TypeVar("_CapabilitiesT", contravariant=True) -class GuideProposalOperationsPort(Protocol[_ActorT, _MaterialT, _PreCapabilitiesT, _PostCapabilitiesT, _PlannerT]): +class GuideProposalOperationsPort(Protocol[_ActorT, _MaterialT, _CapabilitiesT]): """Caller-owned transaction for exact finalized proposal operations.""" async def review_package( - self, selection: GuideProposalSelection, *, actor: _ActorT, request_id: UUID, + self, + selection: GuideProposalSelection, + *, + actor: _ActorT, + request_id: UUID, ) -> "GuideProposalReviewPackage": ... async def approve( - self, command: GuideProposalApproval, *, actor: _ActorT, request_id: UUID, - material: _MaterialT, pre_capabilities: _PreCapabilitiesT, - post_capabilities: _PostCapabilitiesT, planner: _PlannerT, + self, + command: GuideProposalApproval, + *, + actor: _ActorT, + request_id: UUID, + material: _MaterialT, + capabilities: _CapabilitiesT, ) -> GuideProposalApprovalReceipt: ... async def request_correction( - self, command: GuideProposalCorrection, *, actor: _ActorT, request_id: UUID, + self, + command: GuideProposalCorrection, + *, + actor: _ActorT, + request_id: UUID, ) -> GuideProposalCorrectionReceipt: ... @@ -233,6 +261,9 @@ class GuideCorrectionDispatchPort(Protocol[_ActorT]): """Own human correction admission and publication after commit.""" async def dispatch( - self, selection: GuideProposalSelection, correction_operation_id: UUID, *, + self, + selection: GuideProposalSelection, + correction_operation_id: UUID, + *, actor: _ActorT, ) -> GuideProposalDispatchResponse: ... diff --git a/backend/app/modules/projects/api/locked_policy.py b/backend/app/modules/projects/api/locked_policy.py index 3f7436369..9b5b27d67 100644 --- a/backend/app/modules/projects/api/locked_policy.py +++ b/backend/app/modules/projects/api/locked_policy.py @@ -169,6 +169,7 @@ class ProjectLockedPolicyContextFacts: activation_receipt: GuideActivationReceipt artifact_policy: CanonicalJsonObject compiled_post_submit_policy: CanonicalJsonObject + external_checker_policies: CanonicalJsonObject review_policy: CanonicalJsonObject review_semantics_format: ReviewSemanticsFormat revision_policy: CanonicalJsonObject @@ -200,6 +201,7 @@ def __post_init__(self) -> None: self.compiled_pre_submit_bundle, self.artifact_policy, self.compiled_post_submit_policy, + self.external_checker_policies, self.review_policy, self.revision_policy, ) @@ -220,6 +222,25 @@ def __post_init__(self) -> None: ): raise ValueError("project display facts differ from locked context") target, upstream = receipt.command.target.proposal, receipt.command.target.upstream + from app.modules.checkers.api.external_policy import ( + validate_stored_external_checker_policies, + ) + + external_policies = validate_stored_external_checker_policies( + json.loads(self.external_checker_policies.value) + ) + pre_body = json.loads(self.compiled_pre_submit_bundle.value) + post_body = json.loads(self.compiled_post_submit_policy.value) + external_hashes_match = ( + external_policies.policies_sha256 + == receipt.command.target.upstream.effective_pre_submit_plan_hash + and external_policies.pre_submit.model_dump(mode="json") == pre_body + and external_policies.pre_submit.policy_sha256 + == self.pre_submit_policy_bundle_hash + and external_policies.post_submit.model_dump(mode="json") == post_body + and external_policies.post_submit.policy_sha256 + == receipt.command.target.policy_hash + ) if ( ( self.project_id, @@ -248,9 +269,8 @@ def __post_init__(self) -> None: upstream.pre_submit_bundle_hash, ) or self.effective_policy.sha256 != self.effective_policy_hash - or self.compiled_pre_submit_bundle.sha256 != self.pre_submit_policy_bundle_hash + or not external_hashes_match or self.artifact_policy.sha256 != target.artifact_policy_hash - or self.compiled_post_submit_policy.sha256 != receipt.command.target.policy_hash ): raise ValueError("project locked policy facts differ from activation") require_complete_policy( diff --git a/backend/app/modules/projects/authorization_reads.py b/backend/app/modules/projects/authorization_reads.py index d38654f39..a5efe2ce1 100644 --- a/backend/app/modules/projects/authorization_reads.py +++ b/backend/app/modules/projects/authorization_reads.py @@ -37,6 +37,7 @@ ProjectService, ProjectServiceError, ) +from app.modules.checkers.api.external_policy import validate_stored_external_pre_policy DiagnosticRecord: TypeAlias = ProjectSetupRun | GuideSufficiencyReport | SubmissionArtifactPolicy DiagnosticResult: TypeAlias = ( @@ -78,9 +79,8 @@ def _effective_policy_chain_is_consistent( ) ) if not common_matches or checker is None: - return common_matches - if not isinstance(checker.compiled_bundle, dict): return False + compiled = validate_stored_external_pre_policy(checker.compiled_bundle) return all( ( checker.project_id == project_id, @@ -91,7 +91,7 @@ def _effective_policy_chain_is_consistent( checker.effective_policy_id == effective.id, checker.effective_policy_hash == effective.effective_policy_hash, checker.lifecycle_status == "compiled", - canonical_json_hash(checker.compiled_bundle) == checker.compiled_bundle_hash, + compiled.policy_sha256 == checker.compiled_bundle_hash, ) ) except (AttributeError, TypeError, ValueError): @@ -149,7 +149,6 @@ async def authorize_project_policy_read( effective is not None and effective.guide_id == guide_id and effective.source_snapshot_hash == snapshot.bundle_hash - and action_id is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ ): checker = await repository.lock_compiled_pre_submit_checker_policy(effective.id) except ProjectRepositoryIntegrityError: @@ -236,9 +235,27 @@ async def authorize_project_policy_read( effective_policy_id=UUID(effective.id) if target_exists else None, effective_policy_hash=effective.effective_policy_hash if target_exists else None, effective_policy_status=effective.lifecycle_status if target_exists else None, - checker_policy_id=UUID(checker.id) if checker is not None and target_exists else None, - checker_policy_status=(checker.lifecycle_status if checker is not None and target_exists else None), - checker_bundle_hash=(checker.compiled_bundle_hash if checker is not None and target_exists else None), + checker_policy_id=( + UUID(checker.id) + if checker is not None + and target_exists + and action_id is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else None + ), + checker_policy_status=( + checker.lifecycle_status + if checker is not None + and target_exists + and action_id is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else None + ), + checker_bundle_hash=( + checker.compiled_bundle_hash + if checker is not None + and target_exists + and action_id is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else None + ), target_binding_digest=binding_digest, ), ) diff --git a/backend/app/modules/projects/guide_activation/service.py b/backend/app/modules/projects/guide_activation/service.py index 2de57710b..a4b56f1d5 100644 --- a/backend/app/modules/projects/guide_activation/service.py +++ b/backend/app/modules/projects/guide_activation/service.py @@ -6,7 +6,10 @@ from app.core.identifiers import new_record_id from app.modules.authorization.api import ActorKind -from app.modules.checkers.api.pre_submit import EffectivePreSubmissionPlanLineage +from app.modules.checkers.api.external_policy import ( + validate_stored_external_checker_policies, +) +from app.modules.checkers.api.external import ExternalCheckerRegistryPort from app.modules.projects.api.guide_activation import ( GuideActivationCommand, GuideActivationFacts, @@ -15,9 +18,8 @@ GuideContributionPolicyFacts, PreparedGuideActivation, ) -from app.modules.projects.api.guide_proposals import GuideProposalError +from app.modules.projects.api.guide_proposals import GuideProposalError, MissingCheckerCapability from app.modules.projects.api.post_policy import PostPolicySelection -from app.modules.projects.guide_compilation.proposal_approval import require_catalogues from app.modules.projects.guide_compilation.proposal_service import GuideProposalService from app.modules.projects.guide_mutation_repository import GuideMutationRepository from app.modules.projects.post_policy.repository import PostPolicyRepository @@ -39,19 +41,20 @@ class GuideActivationService: """AUTH-12H supplies live manager authority; composition without it denies.""" def __init__( - self, session, *, contribution, planner, pre_catalogue, post_catalogue, authorization=None + self, + session, + *, + contribution, + authorization=None, + registry: ExternalCheckerRegistryPort | None = None, ): self.session = session self.authorization = authorization self.contribution = contribution - self.planner, self.pre_catalogue, self.post_catalogue = ( - planner, - pre_catalogue, - post_catalogue, - ) self.projects = ProjectRepository(session) self.replay = GuideMutationRepository(session) self.post = PostPolicyRepository(session) + self.registry = registry async def activate( self, command: GuideActivationCommand, *, actor, request_id: UUID @@ -150,7 +153,7 @@ async def activate( GuideActivationBlocked, persisted_items=source_items, ) - self._readiness(command, locked, policy, post_custody, review, revision) + await self._readiness(command, locked, policy, post_custody, review, revision) except ProjectServiceError: raise GuideProposalError("approval_blocked") from None try: @@ -218,10 +221,38 @@ async def activate( await self.replay.complete(record, response_json=receipt.model_dump(mode="json")) return receipt - def _readiness(self, command, locked, policy, post, review, revision): - target, approval = command.target.proposal, locked.view.approval_custody - require_catalogues(target, self.pre_catalogue, self.post_catalogue) - post.compiled.validate_catalogue(self.post_catalogue) + async def _readiness(self, command, locked, policy, post, review, revision): + approval = locked.view.approval_custody + external = ( + approval.operation.effective_pre_submit_plan.get("schema_version") + == "external_checker_policies.v1" + ) + if not external or self.registry is None: + raise GuideProposalError("approval_blocked") + policies = validate_stored_external_checker_policies( + approval.operation.effective_pre_submit_plan + ) + if policies.pre_submit.model_dump(mode="json") != approval.pre.compiled_bundle: + raise GuideProposalError("approval_blocked") + for binding in (*policies.pre_submit.entries, *policies.post_submit.entries): + current = await self.registry.read_exact( + binding.registry.registry_entry_id, + binding.registry.entry_digest, + ) + if current != binding.registry: + raise GuideProposalError("approval_blocked") + if locked.result.capability_suggestions: + raise GuideProposalError( + "approval_blocked", + missing_capabilities=tuple( + MissingCheckerCapability( + requirement_id=item.requirement_id, + stage=item.stage, + title=item.title, + ) + for item in locked.result.capability_suggestions + ), + ) ProjectService(self.session).validate_activation_ready( locked.view.guide, locked.view.snapshot, @@ -235,24 +266,3 @@ def _readiness(self, command, locked, policy, post, review, revision): approval_custody=approval, post_policy_custody=post, ) - upstream = command.target.upstream - plan = self.planner.compile_effective_plan( - lineage=EffectivePreSubmissionPlanLineage( - project_id=target.project_id, - guide_id=target.guide_id, - guide_version=target.guide_version, - source_snapshot_id=target.source_snapshot_id, - source_snapshot_hash=target.source_snapshot_hash, - effective_policy_id=upstream.effective_policy_id, - effective_policy_hash=upstream.effective_policy_hash, - pre_submit_policy_id=upstream.pre_submit_policy_id, - pre_submit_policy_bundle_hash=upstream.pre_submit_bundle_hash, - ), - effective_policy=approval.effective.effective_policy, - compiled_bundle=approval.pre.compiled_bundle, - ) - if ( - plan.plan_sha256 != upstream.effective_pre_submit_plan_hash - or plan.catalogue_manifest_sha256 != target.pre_catalogue_manifest_hash - ): - raise GuideProposalError("approval_blocked") diff --git a/backend/app/modules/projects/guide_compilation/approval_custody.py b/backend/app/modules/projects/guide_compilation/approval_custody.py index 1664fe030..8b44ca350 100644 --- a/backend/app/modules/projects/guide_compilation/approval_custody.py +++ b/backend/app/modules/projects/guide_compilation/approval_custody.py @@ -17,6 +17,9 @@ SubmissionPolicyMutationIdempotencyRecord, SubmissionArtifactPolicy, ) +from app.modules.checkers.api.external_policy import ( + validate_stored_external_checker_policies, +) from .custody_payloads import policy_digest from .models import ProjectGuideProposalApproval @@ -84,20 +87,16 @@ async def load_approval_custody(session, policy_id: str | None) -> ApprovalCusto return ApprovalCustody(operation, reservation, effective, pre, successor) -def approved_projection_digest(view) -> str: - """Return the original digest only after proving the exact approved transition.""" - custody = view.approval_custody - policy = view.policy - if custody is None or policy is None: - raise ValueError("approval custody is unavailable") +def _approval_projection_matches( + view, custody, target, receipt, policy, external_policies +) -> bool: + """Compare every immutable target, receipt, reservation and policy fact.""" operation, reservation, effective, pre = ( custody.operation, custody.reservation, custody.effective, custody.pre, ) - target = GuideProposalTarget.model_validate(operation.target_json) - receipt = GuideProposalApprovalReceipt.model_validate(operation.receipt_json) if ( target.digest != operation.target_digest or target.compilation_id != view.compilation.id @@ -138,14 +137,36 @@ def approved_projection_digest(view) -> str: or effective.effective_policy_hash != receipt.effective_policy_hash or canonical_json_hash(effective.effective_policy) != receipt.effective_policy_hash or pre.compiled_bundle_hash != receipt.pre_submit_bundle_hash - or canonical_json_hash(pre.compiled_bundle) != receipt.pre_submit_bundle_hash + or external_policies.pre_submit.policy_sha256 != receipt.pre_submit_bundle_hash or effective.submission_artifact_policy_id != policy.id or effective.submission_artifact_policy_hash != policy.policy_hash or pre.effective_policy_id != effective.id or pre.effective_policy_hash != effective.effective_policy_hash or operation.effective_pre_submit_plan_hash != receipt.effective_pre_submit_plan_hash - or canonical_json_hash(operation.effective_pre_submit_plan) - != receipt.effective_pre_submit_plan_hash + or external_policies.policies_sha256 != receipt.effective_pre_submit_plan_hash + ): + return False + return True + + +def approved_projection_digest(view) -> str: + """Return the original digest only after proving the exact approved transition.""" + custody = view.approval_custody + policy = view.policy + if custody is None or policy is None: + raise ValueError("approval custody is unavailable") + operation, effective, pre = ( + custody.operation, + custody.effective, + custody.pre, + ) + target = GuideProposalTarget.model_validate(operation.target_json) + receipt = GuideProposalApprovalReceipt.model_validate(operation.receipt_json) + external_policies = validate_stored_external_checker_policies( + operation.effective_pre_submit_plan + ) + if not _approval_projection_matches( + view, custody, target, receipt, policy, external_policies ): raise ValueError("approved proposal custody mismatch") lifecycle = (policy.lifecycle_status, effective.lifecycle_status, pre.lifecycle_status) @@ -184,28 +205,11 @@ def _require_plan_and_provenance(view, custody, target) -> None: custody.pre, view.policy, ) - expected_lineage = { - "project_id": str(target.project_id), - "guide_id": str(target.guide_id), - "guide_version": target.guide_version, - "source_snapshot_id": str(target.source_snapshot_id), - "source_snapshot_hash": target.source_snapshot_hash, - "effective_policy_id": effective.id, - "effective_policy_hash": effective.effective_policy_hash, - "pre_submit_policy_id": pre.id, - "pre_submit_policy_bundle_hash": pre.compiled_bundle_hash, - } - expected_catalogue = { - "id": target.pre_catalogue_id, - "version": target.pre_catalogue_version, - "schema_version": target.pre_catalogue_schema_version, - "manifest_sha256": target.pre_catalogue_manifest_hash, - } - if ( - operation.effective_pre_submit_plan.get("lineage") != expected_lineage - or operation.effective_pre_submit_plan.get("catalogue") != expected_catalogue - ): - raise ValueError("approved proposal plan lineage mismatch") + policies = validate_stored_external_checker_policies( + operation.effective_pre_submit_plan + ) + if policies.pre_submit.model_dump(mode="json") != pre.compiled_bundle: + raise ValueError("approved external checker policy mismatch") for name in ( "pre_catalogue_id", "pre_catalogue_version", diff --git a/backend/app/modules/projects/guide_compilation/context.py b/backend/app/modules/projects/guide_compilation/context.py index 07ad01471..9ba81cf09 100644 --- a/backend/app/modules/projects/guide_compilation/context.py +++ b/backend/app/modules/projects/guide_compilation/context.py @@ -17,12 +17,7 @@ ProjectGuideCompilationContext, canonical_project_guide_compilation_context_bytes, ) -from app.modules.checkers.api.pre_submit_catalogue import ( - PreSubmissionCapabilityProjection, -) -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitCatalogue, -) +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.projects.models import GuideSourceSnapshot, ProjectGuide from app.modules.projects.api.task_examples import require_task_example_commitment @@ -36,8 +31,7 @@ async def build_project_guide_compilation_context( *, state: CompilationExecutionState, material: GuideDocumentManifestPort, - pre_submission_capabilities: PreSubmissionCapabilityProjection, - post_submission_capabilities: PostSubmitCatalogue, + checker_capabilities: ExternalCheckerCapabilityProjection, ) -> ProjectGuideCompilationContext: """Return an immutable context only when every current fact matches custody.""" if session.in_transaction(): @@ -82,8 +76,7 @@ async def build_project_guide_compilation_context( loaded=loaded, setup_run_id=identity.setup_run_id, setup_generation=identity.setup_generation, - pre_submission_capabilities=pre_submission_capabilities, - post_submission_capabilities=post_submission_capabilities, + checker_capabilities=checker_capabilities, runtime_configuration=state.runtime_configuration, correction_feedback=await _correction_feedback(session, identity.setup_run_id), ) @@ -99,8 +92,7 @@ def compilation_context_from_material( loaded, setup_run_id, setup_generation, - pre_submission_capabilities: PreSubmissionCapabilityProjection, - post_submission_capabilities: PostSubmitCatalogue, + checker_capabilities: ExternalCheckerCapabilityProjection, runtime_configuration: ProjectGuideRuntimeConfiguration, correction_feedback=None, ) -> ProjectGuideCompilationContext: @@ -133,8 +125,7 @@ def compilation_context_from_material( agent_identity=PROJECT_GUIDE_COMPILATION_AGENT_IDENTITY, agent_version=PROJECT_GUIDE_COMPILATION_AGENT_VERSION, runtime_configuration=runtime_configuration, - pre_submission_capabilities=pre_submission_capabilities, - post_submission_capabilities=post_submission_capabilities, + checker_capabilities=checker_capabilities, ) if ( len(canonical_project_guide_compilation_context_bytes(context)) diff --git a/backend/app/modules/projects/guide_compilation/contracts.py b/backend/app/modules/projects/guide_compilation/contracts.py index 08c917145..ed31e6dec 100644 --- a/backend/app/modules/projects/guide_compilation/contracts.py +++ b/backend/app/modules/projects/guide_compilation/contracts.py @@ -111,14 +111,14 @@ def from_context(cls, context: ProjectGuideCompilationContext) -> CompilationAtt json.loads(canonical_project_guide_compilation_context_bytes(context)) ), guide_material_hash=material.sha256, - pre_catalogue_id=context.pre_submission_capabilities.catalogue_id, - pre_catalogue_version=context.pre_submission_capabilities.version, - pre_catalogue_schema_version=context.pre_submission_capabilities.schema_version, - pre_catalogue_manifest_hash=context.pre_submission_capabilities.manifest_sha256, - post_catalogue_id=context.post_submission_capabilities.catalogue_id, - post_catalogue_version=context.post_submission_capabilities.source_version, - post_catalogue_schema_version=context.post_submission_capabilities.schema_version, - post_catalogue_manifest_hash=context.post_submission_capabilities.manifest_sha256, + pre_catalogue_id="workstream.external_checker_capabilities", + pre_catalogue_version="v1", + pre_catalogue_schema_version=context.checker_capabilities.schema_version, + pre_catalogue_manifest_hash=context.checker_capabilities.manifest_sha256, + post_catalogue_id="workstream.external_checker_capabilities", + post_catalogue_version="v1", + post_catalogue_schema_version=context.checker_capabilities.schema_version, + post_catalogue_manifest_hash=context.checker_capabilities.manifest_sha256, agent_identity=context.agent_identity, agent_version=context.agent_version, instruction_version=context.instruction_version, diff --git a/backend/app/modules/projects/guide_compilation/live.py b/backend/app/modules/projects/guide_compilation/live.py index eb15387be..e66df72db 100644 --- a/backend/app/modules/projects/guide_compilation/live.py +++ b/backend/app/modules/projects/guide_compilation/live.py @@ -17,8 +17,7 @@ ActorIdentityFacts, ProjectGuideCompilationAuthorizationPort, ) -from app.modules.checkers.api.pre_submit_catalogue import PreSubmissionCapabilityProjection -from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ProjectGuideCompilationDeliveryError, @@ -59,8 +58,7 @@ def __init__( session_factory: async_sessionmaker[AsyncSession], *, material_factory: Callable[[AsyncSession], GuideDocumentManifestPort], - pre_capabilities: PreSubmissionCapabilityProjection, - post_capabilities: PostSubmitCatalogue, + checker_capabilities: ExternalCheckerCapabilityProjection, request_authority: RequestAuthority, configuration_factory: Callable[[], ProjectGuideRuntimeConfiguration], execution: ProjectGuideCompilationExecutionPort, @@ -69,8 +67,7 @@ def __init__( ) -> None: self._sessions = session_factory self._material = material_factory - self._pre = pre_capabilities - self._post = post_capabilities + self._checker_capabilities = checker_capabilities self._request_authority = request_authority self._configuration = configuration_factory self._execution = execution @@ -107,8 +104,7 @@ async def run(self, delivery: ProjectGuideCompilationDelivery) -> dict: authority, request_inputs=CompilationRequestInputs( self._material(session), - self._pre, - self._post, + self._checker_capabilities, configuration, ), ).request_automatic(actor=actor, setup_run_id=delivery.setup_run_id) @@ -236,7 +232,12 @@ async def _admit(self, delivery: ProjectGuideCompilationDelivery): from .delivery_request import manual_delivery_attempt manual_attempt = await manual_delivery_attempt(session, setup, attempt) - return None, attempt is not None, attempt.runtime_configuration if attempt else None, manual_attempt + return ( + None, + attempt is not None, + attempt.runtime_configuration if attempt else None, + manual_attempt, + ) async def _finalize(self, command: ProjectGuideSetupFinalizationCommand) -> dict: """Create or replay the existing finalizer's receipt under fresh authority.""" diff --git a/backend/app/modules/projects/guide_compilation/orchestrator.py b/backend/app/modules/projects/guide_compilation/orchestrator.py index 180890c39..b025f7392 100644 --- a/backend/app/modules/projects/guide_compilation/orchestrator.py +++ b/backend/app/modules/projects/guide_compilation/orchestrator.py @@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.modules.projects.api.guide_documents import GuideDocumentManifestPort, GuideDocumentUnavailable +from app.modules.projects.api.guide_documents import ( + GuideDocumentManifestPort, + GuideDocumentUnavailable, +) from app.interfaces.project_agents import ( ProjectGuideAgentRuntime, ProjectGuideCompilationContext, @@ -20,12 +23,7 @@ require_complete_project_guide_compilation_result, validate_project_guide_compilation_result, ) -from app.modules.checkers.api.pre_submit_catalogue import ( - PreSubmissionCapabilityProjection, -) -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitCatalogue, -) +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.authorization.api import ( ActorIdentityFacts, AuthorizationDenied, @@ -56,7 +54,10 @@ load_compilation_execution_state, ) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.modules.projects.api.guide_documents import GuideDocumentAccessFactory, GuideRuntimeCapabilities +from app.modules.projects.api.guide_documents import ( + GuideDocumentAccessFactory, + GuideRuntimeCapabilities, +) from .runtime_resources import SqlAlchemyGuideRuntimeCustody from app.interfaces.external_services import ExternalServiceAdapterError from app.interfaces.project_agents import ( @@ -89,16 +90,14 @@ def __init__( session_factory: async_sessionmaker[AsyncSession], *, material_factory: Callable[[AsyncSession], GuideDocumentManifestPort], - pre_submission_capabilities: PreSubmissionCapabilityProjection, - post_submission_capabilities: PostSubmitCatalogue, + checker_capabilities: ExternalCheckerCapabilityProjection, authorization_context: GuideCompilationAuthorizationContext, document_access_factory: GuideDocumentAccessFactory, ) -> None: """Store the owner-supplied ports used by each short transaction.""" self._session_factory = session_factory self._material_factory = material_factory - self._pre_submission_capabilities = pre_submission_capabilities - self._post_submission_capabilities = post_submission_capabilities + self._checker_capabilities = checker_capabilities self._authorization_context = authorization_context self._document_access_factory = document_access_factory @@ -106,12 +105,16 @@ def __init__( async def capabilities(self, state, context): """Issue resources only from the orchestrator's winning dispatch branch.""" async with self._document_access_factory( - state.preflight_facts.attempt_id, context.material, context.runtime_configuration, + state.preflight_facts.attempt_id, + context.material, + context.runtime_configuration, ) as documents: yield GuideRuntimeCapabilities( documents=documents, resources=SqlAlchemyGuideRuntimeCustody( - self._session_factory, state.preflight_facts.attempt_id, context.material, + self._session_factory, + state.preflight_facts.attempt_id, + context.material, context.runtime_configuration.runtime_key, ), ) @@ -134,8 +137,7 @@ async def context(self, state: CompilationExecutionState) -> ProjectGuideCompila session, state=state, material=self._material_factory(session), - pre_submission_capabilities=self._pre_submission_capabilities, - post_submission_capabilities=self._post_submission_capabilities, + checker_capabilities=self._checker_capabilities, ) except GuideDocumentUnavailable: raise ProjectGuideCompilationExecutionError("context_unavailable") from None @@ -216,8 +218,7 @@ def project_guide_compilation_execution_port( session_factory: async_sessionmaker[AsyncSession], *, material_factory: Callable[[AsyncSession], GuideDocumentManifestPort], - pre_submission_capabilities: PreSubmissionCapabilityProjection, - post_submission_capabilities: PostSubmitCatalogue, + checker_capabilities: ExternalCheckerCapabilityProjection, authorization_context: GuideCompilationAuthorizationContext, runtime_factory: Callable[[ProjectGuideRuntimeConfiguration], ProjectGuideAgentRuntime], document_access_factory: GuideDocumentAccessFactory, @@ -226,8 +227,7 @@ def project_guide_compilation_execution_port( backend = SqlAlchemyGuideCompilationExecutionBackend( session_factory, material_factory=material_factory, - pre_submission_capabilities=pre_submission_capabilities, - post_submission_capabilities=post_submission_capabilities, + checker_capabilities=checker_capabilities, authorization_context=authorization_context, document_access_factory=document_access_factory, ) @@ -237,8 +237,9 @@ def project_guide_compilation_execution_port( class GuideCompilationExecutionBackend(Protocol): """Exact persistence/context operations required by the state machine.""" - def capabilities(self, state: CompilationExecutionState, - context: ProjectGuideCompilationContext) -> AbstractAsyncContextManager[GuideRuntimeCapabilities]: ... + def capabilities( + self, state: CompilationExecutionState, context: ProjectGuideCompilationContext + ) -> AbstractAsyncContextManager[GuideRuntimeCapabilities]: ... async def load(self, attempt_id: UUID) -> CompilationExecutionState: ... @@ -314,12 +315,16 @@ async def execute( try: result = await runtime.compile_project_guide(context, capabilities) except ProjectGuideCompilationInvalidOutputError as exc: - terminal_receipt = await self._backend.record_invalid(state, exc.failure_code) + terminal_receipt = await self._backend.record_invalid( + state, exc.failure_code + ) else: try: _require_valid_result(context, result) except (TypeError, ValueError): - terminal_receipt = await self._backend.record_invalid(state, "schema_invalid") + terminal_receipt = await self._backend.record_invalid( + state, "schema_invalid" + ) else: await self._backend.record_accepted(state, context, result) accepted = True diff --git a/backend/app/modules/projects/guide_compilation/proposal_approval.py b/backend/app/modules/projects/guide_compilation/proposal_approval.py index 640505711..410ba637d 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_approval.py +++ b/backend/app/modules/projects/guide_compilation/proposal_approval.py @@ -15,12 +15,12 @@ GuideProposalAuthorizationLocator, PreparedGuideProposalOperation, ) -from app.modules.checkers.api.policy_compilation import ( - CompiledPreSubmitCheckerPolicy, PreSubmissionPolicyCompilationPort, -) -from app.modules.checkers.api.pre_submit import ( - EffectivePreSubmissionPlanLineage, - EffectivePreSubmissionExecutionPlan, +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.checkers.api.external_policy import ( + LockedExternalCheckerPolicies, + compile_external_checker_policy_bundle, ) from app.modules.projects.api.guide_proposals import ( GuideProposalApprovalReceipt, @@ -62,15 +62,12 @@ async def approve_proposal( actor, request_id, material, - pre_capabilities, - post_capabilities, - planner: PreSubmissionPolicyCompilationPort, + capabilities: ExternalCheckerCapabilityProjection, + registry: ExternalCheckerRegistryPort, ) -> GuideProposalApprovalReceipt: """Compile and persist in the caller transaction after exact authority closes.""" target = command.target - selector_id = approval_authorization_selector( - actor.actor_profile_id, command.idempotency_key - ) + selector_id = approval_authorization_selector(actor.actor_profile_id, command.idempotency_key) locator = GuideProposalAuthorizationLocator( project_id=target.project_id, guide_id=target.guide_id, @@ -103,8 +100,7 @@ async def approve_proposal( locked, package, command, - pre_capabilities, - post_capabilities, + capabilities, ) outputs = await _compile_approval( session, @@ -112,9 +108,8 @@ async def approve_proposal( command, new_record_id(), material, - pre_capabilities, - post_capabilities, - planner, + capabilities, + registry, ) receipt = outputs.receipt facts = _facts(locator, target, request_digest, receipt, command) @@ -172,13 +167,12 @@ async def _validate_new_approval( locked, package, command, - pre_capabilities, - post_capabilities, + capabilities, ): """Validate current proposal and predecessor facts before compiling outputs.""" if not locked.current: raise GuideProposalError("proposal_stale") - require_catalogues(command.target, pre_capabilities, post_capabilities) + require_capabilities(command.target, capabilities) if ( locked.result.status == "guide_blocked" or locked.view.policy is None @@ -187,15 +181,12 @@ async def _validate_new_approval( raise GuideProposalError("approval_blocked") if ( locked.view.policy.lifecycle_status != "draft" - or package.current_approval_operation_id - != command.expected_previous_approval_operation_id + or package.current_approval_operation_id != command.expected_previous_approval_operation_id or package.current_approval_output_digest != command.expected_previous_approval_output_digest ): raise GuideProposalError("proposal_stale") - return await _previous_outputs( - session, command.expected_previous_approval_operation_id - ) + return await _previous_outputs(session, command.expected_previous_approval_operation_id) @dataclass(frozen=True) @@ -204,7 +195,7 @@ class _CompiledApproval: effective_body: dict compiled: CompiledPreSubmitCheckerPolicy - plan: EffectivePreSubmissionExecutionPlan + policies: LockedExternalCheckerPolicies receipt: GuideProposalApprovalReceipt @@ -214,15 +205,13 @@ async def _compile_approval( command, operation_id, material, - pre_capabilities, - post_capabilities, - planner, + capabilities, + registry, ) -> _CompiledApproval: target = command.target inputs = CompilationRequestInputs( material, - pre_capabilities, - post_capabilities, + capabilities, ProjectGuideRuntimeConfiguration.model_validate(locked.view.attempt.runtime_configuration), ) context = await inputs.context_for_setup(session, locked.view.setup) @@ -231,41 +220,21 @@ async def _compile_approval( locked.view.policy.policy_body, ) effective_hash = canonical_json_hash(effective_body) - compiled = planner.compile_policy_bundle( - effective_policy=effective_body, effective_policy_hash=effective_hash, + policies = await compile_external_checker_policy_bundle(locked.result, registry) + external_pre = policies.pre_submit + compiled = CompiledPreSubmitCheckerPolicy( + compiler_version="external_checker_policy.v1", + compiled_bundle=external_pre.model_dump(mode="json"), + compiled_bundle_hash=external_pre.policy_sha256, + checker_names=[ + external_pre.workstream_default.checker_id, + *(entry.registry.capability_id for entry in external_pre.entries), + ], + checker_configs={ + entry.requirement_id: entry.configuration for entry in external_pre.entries + }, ) effective_id, pre_id = new_record_id(), new_record_id() - version = target.guide_version - plan = planner.compile_effective_plan( - lineage=EffectivePreSubmissionPlanLineage( - project_id=target.project_id, - guide_id=target.guide_id, - guide_version=version, - source_snapshot_id=target.source_snapshot_id, - source_snapshot_hash=target.source_snapshot_hash, - effective_policy_id=effective_id, - effective_policy_hash=effective_hash, - pre_submit_policy_id=pre_id, - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, - ), - effective_policy=effective_body, - compiled_bundle=compiled.compiled_bundle, - ) - if plan.catalogue_manifest_sha256 != target.pre_catalogue_manifest_hash: - raise GuideProposalError("approval_blocked") - # Catalogue-valid intent is not enforcement: optional policy primitives are - # emitted only when the actual merged policy configures them. CHECKERS owns - # that configuration validation; reconcile selections with its resulting plan. - enforced = { - (entry.definition_id, entry.definition_version) - for entry in plan.entries - if entry.checker_definition_state == "enabled" - } - if any( - (binding.capability_id, binding.capability_version) not in enforced - for binding in locked.result.pre_submit_bindings - ): - raise GuideProposalError("approval_blocked") receipt = GuideProposalApprovalReceipt( operation_id=operation_id, target_digest=target.digest, @@ -274,10 +243,10 @@ async def _compile_approval( effective_policy_hash=effective_hash, pre_submit_policy_id=pre_id, pre_submit_bundle_hash=compiled.compiled_bundle_hash, - effective_pre_submit_plan_hash=plan.plan_sha256, + effective_pre_submit_plan_hash=policies.policies_sha256, acknowledged_warning_hashes=command.acknowledged_warning_hashes, ) - return _CompiledApproval(effective_body, compiled, plan, receipt) + return _CompiledApproval(effective_body, compiled, policies, receipt) async def _persist_approval( @@ -356,23 +325,29 @@ def _facts(locator, target, request_digest, receipt, command): ) -def require_catalogues(target, pre, post): +def require_capabilities(target, capabilities): + """Bind approval to the exact registry-backed compiler input snapshot.""" + capabilities = ExternalCheckerCapabilityProjection.model_validate(capabilities) if ( - not pre.available - or (pre.catalogue_id, pre.version, pre.schema_version, pre.manifest_sha256) - != ( - target.pre_catalogue_id, - target.pre_catalogue_version, - target.pre_catalogue_schema_version, - target.pre_catalogue_manifest_hash, - ) - or (post.catalogue_id, post.source_version, post.schema_version, post.manifest_sha256) - != ( - target.post_catalogue_id, - target.post_catalogue_version, - target.post_catalogue_schema_version, - target.post_catalogue_manifest_hash, - ) + "workstream.external_checker_capabilities", + "v1", + capabilities.schema_version, + capabilities.manifest_sha256, + ) != ( + target.pre_catalogue_id, + target.pre_catalogue_version, + target.pre_catalogue_schema_version, + target.pre_catalogue_manifest_hash, + ) or ( + "workstream.external_checker_capabilities", + "v1", + capabilities.schema_version, + capabilities.manifest_sha256, + ) != ( + target.post_catalogue_id, + target.post_catalogue_version, + target.post_catalogue_schema_version, + target.post_catalogue_manifest_hash, ): raise GuideProposalError("proposal_stale") @@ -505,7 +480,7 @@ async def _commit_provenance( session, replay_repository, command, actor, outputs, facts, authority, policy, effective, pre ): """Commit the existing reservation and its immutable approval provenance together.""" - target, receipt, plan = command.target, outputs.receipt, outputs.plan + target, receipt = command.target, outputs.receipt operation_id, request_digest = receipt.operation_id, facts.request_digest await replay_repository.complete( operation_id, @@ -538,8 +513,8 @@ async def _commit_provenance( effective_policy_id=effective.id, pre_submit_policy_id=pre.id, approved_policy_output_digest=policy_digest(policy), - effective_pre_submit_plan=plan.as_dict(), - effective_pre_submit_plan_hash=plan.plan_sha256, + effective_pre_submit_plan=outputs.policies.model_dump(mode="json"), + effective_pre_submit_plan_hash=outputs.policies.policies_sha256, prior_approval_operation_id=command.expected_previous_approval_operation_id, actor_profile_id=str(actor.actor_profile_id), identity_link_id=str(actor.identity_link_id), diff --git a/backend/app/modules/projects/guide_compilation/proposal_service.py b/backend/app/modules/projects/guide_compilation/proposal_service.py index 9c3d22313..00374e2a3 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_service.py +++ b/backend/app/modules/projects/guide_compilation/proposal_service.py @@ -30,9 +30,8 @@ GuideProposalCorrectionReceipt, ) from app.modules.projects.api.guide_documents import GuideDocumentManifestPort -from app.modules.checkers.api.pre_submit_catalogue import PreSubmissionCapabilityProjection -from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue -from app.modules.checkers.api.policy_compilation import PreSubmissionPolicyCompilationPort +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.modules.checkers.api.external import ExternalCheckerRegistryPort from app.modules.projects.api.guide_proposal_package import GuideProposalReviewPackage from .proposal_repository import GuideProposalRepository @@ -46,10 +45,12 @@ def __init__( self, session: AsyncSession, authorization: GuideProposalAuthorizationPort, + registry: ExternalCheckerRegistryPort | None = None, ) -> None: self.session = session self.authorization = authorization self.repository = GuideProposalRepository(session) + self.registry = registry async def review_package( self, @@ -101,14 +102,14 @@ async def approve( actor: ActorIdentityFacts, request_id: UUID, material: GuideDocumentManifestPort, - pre_capabilities: PreSubmissionCapabilityProjection, - post_capabilities: PostSubmitCatalogue, - planner: PreSubmissionPolicyCompilationPort, + capabilities: ExternalCheckerCapabilityProjection, ) -> GuideProposalApprovalReceipt: """Approve through the sole approval owner, without committing or invoking a model.""" from .proposal_approval import approve_proposal self._require_transaction() + if self.registry is None: + raise GuideProposalError("proposal_unavailable") async with self._bounded_errors(): return await approve_proposal( self.session, @@ -117,9 +118,8 @@ async def approve( actor=actor, request_id=request_id, material=material, - pre_capabilities=pre_capabilities, - post_capabilities=post_capabilities, - planner=planner, + capabilities=capabilities, + registry=self.registry, ) 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 d9be2c9bb..2e25335b6 100644 --- a/backend/app/modules/projects/guide_compilation/request_inputs.py +++ b/backend/app/modules/projects/guide_compilation/request_inputs.py @@ -11,12 +11,7 @@ GuideDocumentManifestPort, GuideDocumentManifestRequest, ) -from app.modules.checkers.api.pre_submit_catalogue import ( - PreSubmissionCapabilityProjection, -) -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitCatalogue, -) +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.authorization.api import ( ProjectGuideCompilationRequestFacts, ProjectGuideCompilationRequestOrigin, @@ -42,8 +37,7 @@ class CompilationRequestInputs: """Composition supplies material access and canonical CHECKERS projections, never a runtime.""" material: GuideDocumentManifestPort - pre_submission_capabilities: PreSubmissionCapabilityProjection - post_submission_capabilities: PostSubmitCatalogue + checker_capabilities: ExternalCheckerCapabilityProjection runtime_configuration: ProjectGuideRuntimeConfiguration | None async def resolve( @@ -137,8 +131,7 @@ async def context_for_setup(self, session: AsyncSession, setup: ProjectSetupRun) loaded=loaded, setup_run_id=setup_run_id, setup_generation=setup.setup_generation, - pre_submission_capabilities=self.pre_submission_capabilities, - post_submission_capabilities=self.post_submission_capabilities, + checker_capabilities=self.checker_capabilities, runtime_configuration=self.runtime_configuration, correction_feedback=await load_correction_feedback(session, setup_run_id), ) diff --git a/backend/app/modules/projects/locked_policy_projection.py b/backend/app/modules/projects/locked_policy_projection.py index 5210e4140..b3435bebe 100644 --- a/backend/app/modules/projects/locked_policy_projection.py +++ b/backend/app/modules/projects/locked_policy_projection.py @@ -73,6 +73,9 @@ def complete_context(locked, post_policy, post_custody, receipt, review, revisio activation_receipt=receipt, artifact_policy=CanonicalJsonObject.from_mapping(view.policy.policy_body), compiled_post_submit_policy=CanonicalJsonObject.from_mapping(post_policy.policy_body), + external_checker_policies=CanonicalJsonObject.from_mapping( + approval.operation.effective_pre_submit_plan + ), review_policy=_policy_body("review", review, command.review, view.guide), review_semantics_format=review.semantics_format, revision_policy=_policy_body("revision", revision, command.revision, view.guide), diff --git a/backend/app/modules/projects/post_policy/compiler.py b/backend/app/modules/projects/post_policy/compiler.py index b906deecc..e6850c1c3 100644 --- a/backend/app/modules/projects/post_policy/compiler.py +++ b/backend/app/modules/projects/post_policy/compiler.py @@ -1,43 +1,41 @@ -"""Connect validated saved bindings to the sole canonical policy compiler.""" - -from uuid import UUID +"""Project one approved immutable external POST policy without a catalogue.""" from app.interfaces.project_agents import ( ProjectGuideCompilationResult, - validate_post_submission_bindings, + RequirementDisposition, ) -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy, PostSubmitCatalogue -from app.modules.projects.post_submit_policy import ( - build_project_post_submit_checker_spec, - compile_project_post_submit_checker_spec, +from app.modules.checkers.api.external_policy import ( + LockedExternalCheckerPolicies, + LockedExternalPostSubmitPolicy, ) def compile_saved_post_policy( - *, project_id: UUID, guide_version: str, - result: ProjectGuideCompilationResult, catalogue: PostSubmitCatalogue, -) -> CompiledPostSubmitPolicy: - """Preserve every supported requirement while executing each shared member once.""" - validate_post_submission_bindings(result, catalogue) + *, + result: ProjectGuideCompilationResult, + policies: LockedExternalCheckerPolicies, +) -> LockedExternalPostSubmitPolicy: + """Require the saved result and approved exact bindings to describe one policy.""" + result = ProjectGuideCompilationResult.model_validate(result) + policies = LockedExternalCheckerPolicies.model_validate(policies) if result.status == "guide_blocked" or result.submission_artifact_policy is None: raise ValueError("blocked guide cannot project post-submit policy") - selected = {binding.capability_id for binding in result.post_submit_bindings} - compiled = compile_project_post_submit_checker_spec( - project_id=str(project_id), guide_version=guide_version, - spec=build_project_post_submit_checker_spec( - project_id=str(project_id), guide_version=guide_version, - required_checkers=sorted(selected), - ), - ) - compiled.validate_catalogue(catalogue) - entries = {entry.checker_id: entry for entry in compiled.entries} - for binding in result.post_submit_bindings: - entry = entries.get(binding.capability_id) + requirements = { + item.requirement_id: item + for item in result.requirements + if item.disposition is RequirementDisposition.SUPPORTED_POST_SUBMIT + } + bindings = {item.requirement_id: item for item in result.post_submit_bindings} + entries = {item.requirement_id: item for item in policies.post_submit.entries} + if requirements.keys() != bindings.keys() or bindings.keys() != entries.keys(): + raise ValueError("approved post-submit policy omits a required binding") + for requirement_id, binding in bindings.items(): + entry = entries[requirement_id] if ( - entry is None or entry.classification != "project_required" - or entry.definition_version != binding.capability_version - or entry.configuration.model_dump(mode="json") - != {parameter.name: parameter.value for parameter in binding.parameters} + entry.registry.capability_id != binding.capability_id + or entry.registry.capability_version != binding.capability_version + or entry.stage != binding.stage.value + or entry.configuration != {item.name: item.value for item in binding.parameters} ): - raise ValueError("compiled post-submit policy omits or changes a required binding") - return compiled + raise ValueError("approved post-submit policy changes a required binding") + return policies.post_submit diff --git a/backend/app/modules/projects/post_policy/custody.py b/backend/app/modules/projects/post_policy/custody.py index 719049976..3ba2b7fd1 100644 --- a/backend/app/modules/projects/post_policy/custody.py +++ b/backend/app/modules/projects/post_policy/custody.py @@ -6,7 +6,10 @@ from sqlalchemy import select from app.core.hashing import canonical_json_hash -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy +from app.modules.checkers.api.external_policy import ( + LockedExternalPostSubmitPolicy, + validate_stored_external_post_policy, +) from app.modules.authorization.api import ActorKind from app.modules.authorization.api.post_policy import PostPolicyAuthorityReceipt from app.modules.projects.api.guide_proposals import GuideProposalError @@ -16,8 +19,6 @@ post_policy_derive_authorization_selector, post_policy_human_authorization_selector, ) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue -from app.modules.projects.post_submit_policy import parse_locked_post_submit_checker_policy_body from .models import PostPolicyOperation @@ -27,7 +28,7 @@ class PostPolicyCustody: """One policy and its exact immutable receipts; no current-successor substitution.""" target: PostPolicyTarget - compiled: CompiledPostSubmitPolicy + compiled: LockedExternalPostSubmitPolicy projection: PostPolicyOperation approval: PostPolicyOperation | None correction: PostPolicyOperation | None @@ -38,9 +39,14 @@ def operation_receipt(operation: PostPolicyOperation) -> PostPolicyReceipt: receipt = PostPolicyReceipt.model_validate(operation.receipt_json) target = receipt.target facts = operation.resource_context_json - action = "project.post_submit_checker_policy." + { - "derive": "derive", "approve": "approve", "correction": "correction.request", - }[operation.kind] + action = ( + "project.post_submit_checker_policy." + + { + "derive": "derive", + "approve": "approve", + "correction": "correction.request", + }[operation.kind] + ) selector_id = ( post_policy_derive_authorization_selector(operation.upstream_approval_operation_id) if operation.kind == "derive" @@ -49,7 +55,8 @@ def operation_receipt(operation: PostPolicyOperation) -> PostPolicyReceipt: ) ) if ( - receipt.operation_id != operation.operation_id or receipt.kind != operation.kind + receipt.operation_id != operation.operation_id + or receipt.kind != operation.kind or target.model_dump(mode="json") != operation.target_json or target.digest != operation.target_digest or canonical_json_hash(operation.request_json) != operation.request_digest @@ -60,13 +67,20 @@ def operation_receipt(operation: PostPolicyOperation) -> PostPolicyReceipt: or str(target.proposal.guide_id) != operation.guide_id or target.proposal.compilation_id != operation.compilation_id or target.upstream.operation_id != operation.upstream_approval_operation_id - or (facts.get("lifecycle_status") not in ({"compiled", "approved"} if operation.kind == "correction" else {"compiled"})) - or facts != { + or ( + facts.get("lifecycle_status") + not in ({"compiled", "approved"} if operation.kind == "correction" else {"compiled"}) + ) + or facts + != { "locator": dict( - project_id=operation.project_id, guide_id=operation.guide_id, + project_id=operation.project_id, + guide_id=operation.guide_id, compilation_id=str(operation.compilation_id), - actor_profile_id=operation.actor_profile_id, identity_link_id=operation.identity_link_id, - action_id=action, operation_id=str(selector_id), + actor_profile_id=operation.actor_profile_id, + identity_link_id=operation.identity_link_id, + action_id=action, + operation_id=str(selector_id), ), "policy_id": operation.policy_id, "finalization_id": str(target.proposal.finalization_id), @@ -87,8 +101,10 @@ def operation_receipt(operation: PostPolicyOperation) -> PostPolicyReceipt: "pre_submit_bundle_hash": target.upstream.pre_submit_bundle_hash, "projection_operation_id": str(target.projection_operation_id), "lifecycle_status": facts.get("lifecycle_status"), - "policy_hash": target.policy_hash, "target_digest": target.digest, - "request_digest": operation.request_digest, "output_digest": operation.output_digest, + "policy_hash": target.policy_hash, + "target_digest": target.digest, + "request_digest": operation.request_digest, + "output_digest": operation.output_digest, } ): raise GuideProposalError("proposal_unavailable") @@ -97,30 +113,30 @@ def operation_receipt(operation: PostPolicyOperation) -> PostPolicyReceipt: async def load_post_policy_custody(session, policy) -> PostPolicyCustody: """Require complete projection/approval/supersession evidence, even for reads.""" - operations = {row.kind: row for row in await session.scalars( - select(PostPolicyOperation).where(PostPolicyOperation.policy_id == policy.id).with_for_update().execution_options(populate_existing=True) - )} + operations = { + row.kind: row + for row in await session.scalars( + select(PostPolicyOperation) + .where(PostPolicyOperation.policy_id == policy.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + } projection = operations.get("derive") if projection is None or policy.projection_operation_id != projection.operation_id: raise GuideProposalError("proposal_unavailable") target = operation_receipt(projection).target - compiled = parse_locked_post_submit_checker_policy_body( - policy.policy_body, project_id=policy.project_id, - guide_version=policy.guide_version, policy_hash=policy.policy_hash or "", - ) + compiled = validate_stored_external_post_policy(policy.policy_body) if ( - compiled.catalogue_id, compiled.catalogue_source_version, - compiled.catalogue_schema_version, compiled.catalogue_manifest_sha256, - ) != ( - target.proposal.post_catalogue_id, target.proposal.post_catalogue_version, - target.proposal.post_catalogue_schema_version, target.proposal.post_catalogue_manifest_hash, + compiled.policy_sha256 != policy.policy_hash + or policy.required_checkers != [item.registry.capability_id for item in compiled.entries] + or policy.warning_checkers != [] + or policy.blocking_severities != ["critical", "high"] ): raise GuideProposalError("proposal_unavailable") - compiled.validate_sidecars(required_checkers=policy.required_checkers, - warning_checkers=policy.warning_checkers, - blocking_severities=policy.blocking_severities) expected = { - "project_id": str(target.proposal.project_id), "guide_id": str(target.proposal.guide_id), + "project_id": str(target.proposal.project_id), + "guide_id": str(target.proposal.guide_id), "guide_version": target.proposal.guide_version, "source_snapshot_id": str(target.proposal.source_snapshot_id), "source_snapshot_hash": target.proposal.source_snapshot_hash, @@ -129,7 +145,9 @@ async def load_post_policy_custody(session, policy) -> PostPolicyCustody: "pre_submit_checker_policy_id": str(target.upstream.pre_submit_policy_id), "pre_submit_checker_bundle_hash": target.upstream.pre_submit_bundle_hash, "policy_hash": target.policy_hash, - "supersedes_policy_id": str(target.predecessor_policy_id) if target.predecessor_policy_id else None, + "supersedes_policy_id": str(target.predecessor_policy_id) + if target.predecessor_policy_id + else None, } if any(getattr(policy, key) != value for key, value in expected.items()): raise GuideProposalError("proposal_unavailable") @@ -142,18 +160,24 @@ async def load_post_policy_custody(session, policy) -> PostPolicyCustody: or (policy.approved_at is not None) != (approval is not None) or (policy.lifecycle_status == "approved" and approval is None) or (policy.lifecycle_status == "compiled" and approval is not None) - or (policy.lifecycle_status == "superseded") != (policy.supersession_operation_id is not None) + or (policy.lifecycle_status == "superseded") + != (policy.supersession_operation_id is not None) or (policy.lifecycle_status == "superseded") != (policy.superseded_at is not None) ): raise GuideProposalError("proposal_unavailable") if policy.lifecycle_status == "superseded": - successor = await session.get(PostPolicyOperation, policy.supersession_operation_id, populate_existing=True) + successor = await session.get( + PostPolicyOperation, policy.supersession_operation_id, populate_existing=True + ) if successor is None: raise GuideProposalError("proposal_unavailable") receipt = operation_receipt(successor) if not ( (successor.kind == "correction" and successor is correction) - or (successor.kind == "derive" and receipt.target.predecessor_policy_id == UUID(policy.id)) + or ( + successor.kind == "derive" + and receipt.target.predecessor_policy_id == UUID(policy.id) + ) ): raise GuideProposalError("proposal_unavailable") return PostPolicyCustody(target, compiled, projection, approval, correction) @@ -171,23 +195,35 @@ def require_post_authority(receipt, facts, actor): or receipt.permission_id != "project.effective_policy.manage" or receipt.resource_context_digest != facts.digest or not isinstance(receipt.authorization_decision_event_id, UUID) - or (derive and ( - actor.actor_kind is not ActorKind.SERVICE - or actor.service_identity != "workstream.project.setup" - or receipt.service_identity != "workstream.project.setup" - or receipt.admin_role_grant_id is not None - )) - or (not derive and ( - actor.actor_kind is not ActorKind.HUMAN or receipt.service_identity is not None - or not isinstance(receipt.admin_role_grant_id, UUID) - )) + or ( + derive + and ( + actor.actor_kind is not ActorKind.SERVICE + or actor.service_identity != "workstream.project.setup" + or receipt.service_identity != "workstream.project.setup" + or receipt.admin_role_grant_id is not None + ) + ) + or ( + not derive + and ( + actor.actor_kind is not ActorKind.HUMAN + or receipt.service_identity is not None + or not isinstance(receipt.admin_role_grant_id, UUID) + ) + ) ): raise GuideProposalError("authority_unavailable") def validate_activation_post_policy( - guide, source_snapshot, effective_policy, pre_submit_checker_policy, - post_submit_checker_policy, approval_custody, post_policy_custody, + guide, + source_snapshot, + effective_policy, + pre_submit_checker_policy, + post_submit_checker_policy, + approval_custody, + post_policy_custody, ): """Validate the exact post-policy portion of active-guide readiness.""" if post_submit_checker_policy is None: @@ -199,13 +235,8 @@ def validate_activation_post_policy( if post_submit_checker_policy.source_snapshot_hash != source_snapshot.bundle_hash: raise ValueError("post-submit checker policy snapshot hash mismatch") if post_submit_checker_policy.effective_policy_id != effective_policy.id: - raise ValueError( - "post-submit checker policy is bound to the wrong effective policy" - ) - if ( - post_submit_checker_policy.effective_policy_hash - != effective_policy.effective_policy_hash - ): + raise ValueError("post-submit checker policy is bound to the wrong effective policy") + if post_submit_checker_policy.effective_policy_hash != effective_policy.effective_policy_hash: raise ValueError("post-submit checker policy effective hash mismatch") if post_submit_checker_policy.pre_submit_checker_policy_id != pre_submit_checker_policy.id: raise ValueError( @@ -221,28 +252,25 @@ def validate_activation_post_policy( if ( post_policy_custody is None or post_policy_custody.approval is None - or post_submit_checker_policy.approval_operation_id != post_policy_custody.approval.operation_id - or post_submit_checker_policy.projection_operation_id != post_policy_custody.projection.operation_id + or post_submit_checker_policy.approval_operation_id + != post_policy_custody.approval.operation_id + or post_submit_checker_policy.projection_operation_id + != post_policy_custody.projection.operation_id or post_submit_checker_policy.approved_at is None - or post_policy_custody.target.upstream.operation_id != approval_custody.operation.operation_id + or post_policy_custody.target.upstream.operation_id + != approval_custody.operation.operation_id or post_policy_custody.target.policy_hash != post_submit_checker_policy.policy_hash ): raise ValueError("post-submit checker approval custody is required") try: - parsed_post_submit_policy = parse_locked_post_submit_checker_policy_body( - post_submit_checker_policy.policy_body, - project_id=post_submit_checker_policy.project_id, - guide_version=post_submit_checker_policy.guide_version, - policy_hash=post_submit_checker_policy.policy_hash or "", - ) - parsed_post_submit_policy.validate_catalogue(current_post_submit_catalogue()) - except ValueError as exc: - raise ValueError("post-submit checker policy hash is invalid") from exc - try: - parsed_post_submit_policy.validate_sidecars( - required_checkers=post_submit_checker_policy.required_checkers, - warning_checkers=post_submit_checker_policy.warning_checkers, - blocking_severities=post_submit_checker_policy.blocking_severities, - ) + parsed = validate_stored_external_post_policy(post_submit_checker_policy.policy_body) + if ( + parsed.policy_sha256 != post_submit_checker_policy.policy_hash + or post_submit_checker_policy.required_checkers + != [item.registry.capability_id for item in parsed.entries] + or post_submit_checker_policy.warning_checkers != [] + or post_submit_checker_policy.blocking_severities != ["critical", "high"] + ): + raise ValueError("external post policy sidecars differ") except ValueError as exc: raise ValueError("post-submit checker policy hash is invalid") from exc diff --git a/backend/app/modules/projects/post_policy/delivery.py b/backend/app/modules/projects/post_policy/delivery.py index 35559d7bf..02fde43ed 100644 --- a/backend/app/modules/projects/post_policy/delivery.py +++ b/backend/app/modules/projects/post_policy/delivery.py @@ -9,11 +9,11 @@ from app.modules.authorization.api import ActorIdentityFacts, AuthorizationDenied from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort -from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue from app.modules.projects.api.guide_proposals import GuideProposalError, GuideProposalSelection from app.modules.projects.api.post_policy import PostPolicyDelivery, PostPolicyDerive from app.modules.projects.guide_compilation.models import ( - ProjectGuideCompilation, ProjectGuideProposalApproval, + ProjectGuideCompilation, + ProjectGuideProposalApproval, ) from app.modules.projects.models import ProjectSetupRun, SubmissionArtifactPolicy @@ -27,7 +27,10 @@ async def pending_approval_ids( - session: AsyncSession, *, after: UUID | None, limit: int, + session: AsyncSession, + *, + after: UUID | None, + limit: int, ) -> list[UUID]: """Page metadata only; current custody and authority are rechecked on delivery.""" approval = ProjectGuideProposalApproval @@ -44,7 +47,8 @@ async def pending_approval_ids( .join(SubmissionArtifactPolicy, SubmissionArtifactPolicy.id == approval.artifact_policy_id) .join(ProjectGuideCompilation, ProjectGuideCompilation.id == approval.compilation_id) .where(SubmissionArtifactPolicy.lifecycle_status == "approved", ~completed, ~newer_setup) - .order_by(approval.operation_id).limit(limit) + .order_by(approval.operation_id) + .limit(limit) ) if after is not None: query = query.where(approval.operation_id > after) @@ -55,26 +59,35 @@ class PostPolicyDeliveryService: """Run the existing derive operation in its own fresh authorized transaction.""" def __init__( - self, sessions: async_sessionmaker[AsyncSession], *, - authority: PostPolicyServiceAuthority, catalogue: PostSubmitCatalogue, + self, + sessions: async_sessionmaker[AsyncSession], + *, + authority: PostPolicyServiceAuthority, ) -> None: - self.sessions, self.authority, self.catalogue = sessions, authority, catalogue + self.sessions, self.authority = sessions, authority async def run(self, delivery: PostPolicyDelivery) -> dict: """Reload immutable selection, then leave all policy decisions to its owner.""" delivery = PostPolicyDelivery.model_validate(delivery) async with self.sessions() as session: approval = ProjectGuideProposalApproval - row = (await session.execute(select( - approval.project_id, approval.guide_id, approval.compilation_id, - approval.output_digest, - ).where(approval.operation_id == delivery.approval_operation_id))).one_or_none() + row = ( + await session.execute( + select( + approval.project_id, + approval.guide_id, + approval.compilation_id, + approval.output_digest, + ).where(approval.operation_id == delivery.approval_operation_id) + ) + ).one_or_none() await session.rollback() if row is None: return {"status": "delivery_rejected"} command = PostPolicyDerive( selection=GuideProposalSelection( - project_id=row.project_id, guide_id=row.guide_id, + project_id=row.project_id, + guide_id=row.guide_id, compilation_id=row.compilation_id, ), upstream_approval_operation_id=delivery.approval_operation_id, @@ -83,12 +96,17 @@ async def run(self, delivery: PostPolicyDelivery) -> dict: try: async with self.authority(session, delivery.task_id) as (authority, actor): async with session.begin(): - receipt = await PostPolicyService(session, authority, self.catalogue).derive( - command, actor=actor, request_id=delivery.task_id, + receipt = await PostPolicyService(session, authority).derive( + command, + actor=actor, + request_id=delivery.task_id, ) return {"status": "policy_draft_ready", "policy_id": str(receipt.target.policy_id)} except AuthorizationDenied: return {"status": "delivery_rejected"} except GuideProposalError as exc: - return {"status": "derivation_unavailable" if exc.code == "storage_unavailable" - else "delivery_rejected"} + return { + "status": "derivation_unavailable" + if exc.code == "storage_unavailable" + else "delivery_rejected" + } diff --git a/backend/app/modules/projects/post_policy/service.py b/backend/app/modules/projects/post_policy/service.py index 4a004c95c..6d0478ff4 100644 --- a/backend/app/modules/projects/post_policy/service.py +++ b/backend/app/modules/projects/post_policy/service.py @@ -10,15 +10,28 @@ from app.core.hashing import canonical_json_hash from app.modules.authorization.api import ActorIdentityFacts from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy, PostSubmitCatalogue +from app.modules.checkers.api.external_policy import ( + LockedExternalPostSubmitPolicy, + validate_stored_external_checker_policies, +) from app.modules.authorization.api.post_policy import ( - PostPolicyAuthorizationFacts, PostPolicyAuthorizationLocator, PreparedPostPolicyOperation, + PostPolicyAuthorizationFacts, + PostPolicyAuthorizationLocator, + PreparedPostPolicyOperation, PostPolicyAuthorizationPort, ) -from app.modules.projects.api.guide_proposals import GuideProposalApprovalReceipt, GuideProposalError +from app.modules.projects.api.guide_proposals import ( + GuideProposalApprovalReceipt, + GuideProposalError, +) from app.modules.projects.api.post_policy import ( - PostPolicyReceipt, PostPolicyReviewPackage, PostPolicySelection, PostPolicyTarget, - PostPolicyApproval, PostPolicyCorrection, PostPolicyDerive, + PostPolicyReceipt, + PostPolicyReviewPackage, + PostPolicySelection, + PostPolicyTarget, + PostPolicyApproval, + PostPolicyCorrection, + PostPolicyDerive, post_policy_derive_authorization_selector, post_policy_human_authorization_selector, ) @@ -26,7 +39,9 @@ from app.modules.projects.models import PostSubmitCheckerPolicy from app.modules.projects.repository import ProjectRepository from app.modules.projects.api.guide_activation_context import ( - GuideActivationContext, GuidePolicySelection, GuideContributionDiscoveryPort, + GuideActivationContext, + GuidePolicySelection, + GuideContributionDiscoveryPort, ) from .compiler import compile_saved_post_policy @@ -38,16 +53,23 @@ class PostPolicyService: """Use caller-owned root transactions and explicitly supplied nominal authority.""" - def __init__(self, session: AsyncSession, authorization: PostPolicyAuthorizationPort, catalogue: PostSubmitCatalogue) -> None: - self.session, self.authorization, self.catalogue = session, authorization, catalogue + def __init__(self, session: AsyncSession, authorization: PostPolicyAuthorizationPort) -> None: + self.session, self.authorization = session, authorization self.repository = PostPolicyRepository(session) def _require_transaction(self): - if (not self.session.in_transaction() or self.session.in_nested_transaction() - or self.session.new or self.session.dirty or self.session.deleted): + if ( + not self.session.in_transaction() + or self.session.in_nested_transaction() + or self.session.new + or self.session.dirty + or self.session.deleted + ): raise GuideProposalError("proposal_unavailable") - async def derive(self, command: PostPolicyDerive, *, actor: ActorIdentityFacts, request_id: UUID) -> PostPolicyReceipt: + async def derive( + self, command: PostPolicyDerive, *, actor: ActorIdentityFacts, request_id: UUID + ) -> PostPolicyReceipt: """Project one exact approved result once; no document/runtime dependency exists.""" self._require_transaction() selector_id = post_policy_derive_authorization_selector( @@ -63,18 +85,23 @@ async def derive(self, command: PostPolicyDerive, *, actor: ActorIdentityFacts, ) if existing: receipt = operation_receipt(existing) - _, _, custody = await self.repository.lock_policy(self._selection(receipt.target)) + _, _, custody = await self.repository.lock_policy( + self._selection(receipt.target) + ) if custody.projection.operation_id != existing.operation_id: raise GuideProposalError("operation_conflict") return await self._replay(existing, command, actor, locator, prepared) upstream = await self.repository.require_current_upstream(locked) - if (upstream.operation_id != command.upstream_approval_operation_id - or upstream.output_digest != command.upstream_approval_output_digest): + if ( + upstream.operation_id != command.upstream_approval_operation_id + or upstream.output_digest != command.upstream_approval_output_digest + ): raise GuideProposalError("proposal_stale") - self._require_catalogue(locked.target) compiled = compile_saved_post_policy( - project_id=locked.target.project_id, guide_version=locked.target.guide_version, - result=locked.result, catalogue=self.catalogue, + result=locked.result, + policies=validate_stored_external_checker_policies( + upstream.effective_pre_submit_plan + ), ) previous = await self.repository.latest_policy(locked.target.guide_id) if previous: @@ -83,9 +110,12 @@ async def derive(self, command: PostPolicyDerive, *, actor: ActorIdentityFacts, raise GuideProposalError("proposal_stale") operation_id = new_record_id() target = PostPolicyTarget( - proposal=locked.target, upstream=GuideProposalApprovalReceipt.model_validate(upstream.receipt_json), - upstream_output_digest=upstream.output_digest, policy_id=new_record_id(), - projection_operation_id=operation_id, policy_hash=compiled.policy_hash, + proposal=locked.target, + upstream=GuideProposalApprovalReceipt.model_validate(upstream.receipt_json), + upstream_output_digest=upstream.output_digest, + policy_id=new_record_id(), + projection_operation_id=operation_id, + policy_hash=compiled.policy_sha256, predecessor_policy_id=UUID(previous.id) if previous else None, ) receipt = PostPolicyReceipt(operation_id=operation_id, kind="derive", target=target) @@ -97,10 +127,14 @@ async def derive(self, command: PostPolicyDerive, *, actor: ActorIdentityFacts, self._supersede(previous, operation_id, now, "upstream_policy_changed") await self.session.flush() self.session.add(self._policy_row(target, compiled, actor)) - await self._persist(command, actor, receipt, facts, authority, command.upstream_approval_operation_id) + await self._persist( + command, actor, receipt, facts, authority, command.upstream_approval_operation_id + ) return receipt - async def approve(self, command: PostPolicyApproval, *, actor: ActorIdentityFacts, request_id: UUID) -> PostPolicyReceipt: + async def approve( + self, command: PostPolicyApproval, *, actor: ActorIdentityFacts, request_id: UUID + ) -> PostPolicyReceipt: """Record separate human approval without accepting replacement policy content.""" self._require_transaction() selector_id = self._human_operation_id(actor, command.idempotency_key, "approve") @@ -118,17 +152,20 @@ async def approve(self, command: PostPolicyApproval, *, actor: ActorIdentityFact if existing: return await self._replay(existing, command, actor, locator, prepared) await self.repository.require_current_upstream(locked) - self._require_catalogue(target.proposal) if policy.lifecycle_status != "compiled" or custody.approval is not None: raise GuideProposalError("proposal_stale") compiled = compile_saved_post_policy( - project_id=target.proposal.project_id, guide_version=target.proposal.guide_version, - result=locked.result, catalogue=self.catalogue, + result=locked.result, + policies=validate_stored_external_checker_policies( + locked.view.approval_custody.operation.effective_pre_submit_plan + ), ) - if compiled.policy_hash != target.policy_hash: + if compiled.policy_sha256 != target.policy_hash: raise GuideProposalError("approval_blocked") operation_id = new_record_id() - receipt = PostPolicyReceipt(operation_id=operation_id, kind="approve", target=target) + receipt = PostPolicyReceipt( + operation_id=operation_id, kind="approve", target=target + ) facts = self._facts(locator, command, receipt) authority = await prepared.consume_new(facts) require_post_authority(authority, facts, actor) @@ -138,10 +175,19 @@ async def approve(self, command: PostPolicyApproval, *, actor: ActorIdentityFact await self._persist(command, actor, receipt, facts, authority, command.idempotency_key) return receipt - async def review_package(self, selection: PostPolicySelection, *, actor: ActorIdentityFacts, request_id: UUID, contribution: GuideContributionDiscoveryPort) -> PostPolicyReviewPackage[CompiledPostSubmitPolicy]: + async def review_package( + self, + selection: PostPolicySelection, + *, + actor: ActorIdentityFacts, + request_id: UUID, + contribution: GuideContributionDiscoveryPort, + ) -> PostPolicyReviewPackage[LockedExternalPostSubmitPolicy]: """Disclose the complete exact policy and safe findings through existing read authority.""" self._require_transaction() - locator = self._locator(selection, actor, request_id, uuid5(request_id, "post-policy-read"), "read") + locator = self._locator( + selection, actor, request_id, uuid5(request_id, "post-policy-read"), "read" + ) async with GuideProposalService._bounded_errors(): async with self.authorization.prepare_post_policy_operation(locator) as prepared: self._require_prepared(prepared) @@ -153,41 +199,79 @@ async def review_package(self, selection: PostPolicySelection, *, actor: ActorId guide = locked.view.guide review = await projects.lock_review_policy(guide.project_id, guide.version) revision = await projects.lock_revision_policy(guide.project_id, guide.version) + def selected(row): - return None if row is None else GuidePolicySelection( - policy_id=row.id, generation=row.policy_generation, policy_hash=row.policy_hash, + return ( + None + if row is None + else GuidePolicySelection( + policy_id=row.id, + generation=row.policy_generation, + policy_hash=row.policy_hash, + ) ) + activation = GuideActivationContext( guide_mutation_generation=guide.mutation_generation, - review=selected(review), revision=selected(revision), + review=selected(review), + revision=selected(revision), contribution=await contribution.published_selection(selection.project_id), expected_previous_active_guide_id=previous.id if previous else None, - expected_previous_active_guide_generation=previous.mutation_generation if previous else None, - post_approval_operation_id=custody.approval.operation_id if custody.approval else None, - post_approval_output_digest=custody.approval.output_digest if custody.approval else None, + expected_previous_active_guide_generation=previous.mutation_generation + if previous + else None, + post_approval_operation_id=custody.approval.operation_id + if custody.approval + else None, + post_approval_output_digest=custody.approval.output_digest + if custody.approval + else None, ) proposal = await self.repository.proposals.package(locked) - package = PostPolicyReviewPackage[CompiledPostSubmitPolicy]( - target=custody.target, policy=custody.compiled, proposal=proposal, + package = PostPolicyReviewPackage[LockedExternalPostSubmitPolicy]( + target=custody.target, + policy=custody.compiled, + proposal=proposal, activation_context=activation, lifecycle_status=policy.lifecycle_status, - current=locked.current and policy.lifecycle_status != "superseded" - and proposal.current_approval_operation_id == custody.target.upstream.operation_id, + current=locked.current + and policy.lifecycle_status != "superseded" + and proposal.current_approval_operation_id + == custody.target.upstream.operation_id, approval_operation_id=policy.approval_operation_id, - correction=operation_receipt(custody.correction).correction if custody.correction else None, + correction=operation_receipt(custody.correction).correction + if custody.correction + else None, + ) + facts = self._facts( + locator, + selection, + package, + target=custody.target, + lifecycle_status=policy.lifecycle_status, ) - facts = self._facts(locator, selection, package, target=custody.target, lifecycle_status=policy.lifecycle_status) await prepared.authorize_read(facts) return package - async def request_correction(self, command: PostPolicyCorrection, *, actor: ActorIdentityFacts, request_id: UUID, guide_authorization: GuideProposalAuthorizationPort) -> PostPolicyReceipt: + async def request_correction( + self, + command: PostPolicyCorrection, + *, + actor: ActorIdentityFacts, + request_id: UUID, + guide_authorization: GuideProposalAuthorizationPort, + ) -> PostPolicyReceipt: """Both authorities precede product locks; the existing successor owner persists once.""" from .correction import request_post_policy_correction self._require_transaction() async with GuideProposalService._bounded_errors(): return await request_post_policy_correction( - self, command, actor, request_id, guide_authorization, + self, + command, + actor, + request_id, + guide_authorization, ) @staticmethod @@ -195,19 +279,13 @@ def _require_prepared(prepared): if not isinstance(prepared, PreparedPostPolicyOperation): raise GuideProposalError("authority_unavailable") - def _require_catalogue(self, proposal): - catalogue = self.catalogue - if (catalogue.catalogue_id, catalogue.source_version, catalogue.schema_version, catalogue.manifest_sha256) != ( - proposal.post_catalogue_id, proposal.post_catalogue_version, - proposal.post_catalogue_schema_version, proposal.post_catalogue_manifest_hash, - ): - raise GuideProposalError("proposal_stale") - @staticmethod def _selection(target): return PostPolicySelection( - project_id=target.proposal.project_id, guide_id=target.proposal.guide_id, - compilation_id=target.proposal.compilation_id, policy_id=target.policy_id, + project_id=target.proposal.project_id, + guide_id=target.proposal.guide_id, + compilation_id=target.proposal.compilation_id, + policy_id=target.policy_id, ) @staticmethod @@ -216,41 +294,68 @@ def _human_operation_id(actor, key, kind): @staticmethod def _locator(selection, actor, request_id, operation_id, kind): - action = "project.guide_compilation.review_package.read" if kind == "read" else ( - "project.post_submit_checker_policy." + ("correction.request" if kind == "correction" else kind) + action = ( + "project.guide_compilation.review_package.read" + if kind == "read" + else ( + "project.post_submit_checker_policy." + + ("correction.request" if kind == "correction" else kind) + ) ) return PostPolicyAuthorizationLocator( - project_id=selection.project_id, guide_id=selection.guide_id, compilation_id=selection.compilation_id, - actor_profile_id=actor.actor_profile_id, identity_link_id=actor.identity_link_id, - request_id=request_id, operation_id=operation_id, action_id=action, + project_id=selection.project_id, + guide_id=selection.guide_id, + compilation_id=selection.compilation_id, + actor_profile_id=actor.actor_profile_id, + identity_link_id=actor.identity_link_id, + request_id=request_id, + operation_id=operation_id, + action_id=action, ) @staticmethod def _facts(locator, command, output, *, target=None, lifecycle_status="compiled"): target = target if target is not None else output.target return PostPolicyAuthorizationFacts( - locator=locator, policy_id=target.policy_id, finalization_id=target.proposal.finalization_id, - setup_run_id=target.proposal.setup_run_id, setup_generation=target.proposal.setup_generation, + locator=locator, + policy_id=target.policy_id, + finalization_id=target.proposal.finalization_id, + setup_run_id=target.proposal.setup_run_id, + setup_generation=target.proposal.setup_generation, upstream_approval_operation_id=target.upstream.operation_id, - upstream_approval_output_digest=target.upstream_output_digest, policy_hash=target.policy_hash, - guide_version=target.proposal.guide_version, source_snapshot_id=target.proposal.source_snapshot_id, - source_snapshot_hash=target.proposal.source_snapshot_hash, result_hash=target.proposal.result_hash, + upstream_approval_output_digest=target.upstream_output_digest, + policy_hash=target.policy_hash, + guide_version=target.proposal.guide_version, + source_snapshot_id=target.proposal.source_snapshot_id, + source_snapshot_hash=target.proposal.source_snapshot_hash, + result_hash=target.proposal.result_hash, post_component_hash=target.proposal.component_hashes.post_submit_hash, requirement_inventory_hash=target.proposal.component_hashes.requirement_inventory_hash, catalogue_manifest_hash=target.proposal.post_catalogue_manifest_hash, - effective_policy_id=target.upstream.effective_policy_id, effective_policy_hash=target.upstream.effective_policy_hash, - pre_submit_policy_id=target.upstream.pre_submit_policy_id, pre_submit_bundle_hash=target.upstream.pre_submit_bundle_hash, - projection_operation_id=target.projection_operation_id, lifecycle_status=lifecycle_status, - target_digest=target.digest, request_digest=canonical_json_hash(command.model_dump(mode="json")), + effective_policy_id=target.upstream.effective_policy_id, + effective_policy_hash=target.upstream.effective_policy_hash, + pre_submit_policy_id=target.upstream.pre_submit_policy_id, + pre_submit_bundle_hash=target.upstream.pre_submit_bundle_hash, + projection_operation_id=target.projection_operation_id, + lifecycle_status=lifecycle_status, + target_digest=target.digest, + request_digest=canonical_json_hash(command.model_dump(mode="json")), output_digest=canonical_json_hash(output.model_dump(mode="json")), ) async def _replay(self, existing, command, actor, locator, prepared): receipt = operation_receipt(existing) - facts = self._facts(locator, command, receipt, lifecycle_status=existing.resource_context_json["lifecycle_status"]) - if (existing.actor_profile_id != str(actor.actor_profile_id) - or existing.identity_link_id != str(actor.identity_link_id) - or facts.digest != existing.resource_context_digest): + facts = self._facts( + locator, + command, + receipt, + lifecycle_status=existing.resource_context_json["lifecycle_status"], + ) + if ( + existing.actor_profile_id != str(actor.actor_profile_id) + or existing.identity_link_id != str(actor.identity_link_id) + or facts.digest != existing.resource_context_digest + ): raise GuideProposalError("operation_conflict") await prepared.validate_replay(facts, UUID(existing.authorization_decision_event_id)) return receipt @@ -266,35 +371,57 @@ def _supersede(policy, operation_id, now, kind): def _policy_row(target, compiled, actor): proposal, upstream = target.proposal, target.upstream return PostSubmitCheckerPolicy( - id=str(target.policy_id), project_id=str(proposal.project_id), guide_id=str(proposal.guide_id), - guide_version=proposal.guide_version, source_snapshot_id=str(proposal.source_snapshot_id), - source_snapshot_hash=proposal.source_snapshot_hash, effective_policy_id=str(upstream.effective_policy_id), + id=str(target.policy_id), + project_id=str(proposal.project_id), + guide_id=str(proposal.guide_id), + guide_version=proposal.guide_version, + source_snapshot_id=str(proposal.source_snapshot_id), + source_snapshot_hash=proposal.source_snapshot_hash, + effective_policy_id=str(upstream.effective_policy_id), effective_policy_hash=upstream.effective_policy_hash, pre_submit_checker_policy_id=str(upstream.pre_submit_policy_id), pre_submit_checker_bundle_hash=upstream.pre_submit_bundle_hash, - required_checkers=compiled.required_checkers, warning_checkers=compiled.warning_checkers, - blocking_severities=list(compiled.blocking_severities), policy_body=compiled.policy_body, - policy_hash=compiled.policy_hash, lifecycle_status="compiled", + required_checkers=[item.registry.capability_id for item in compiled.entries], + warning_checkers=[], + blocking_severities=["critical", "high"], + policy_body=compiled.model_dump(mode="json"), + policy_hash=compiled.policy_sha256, + lifecycle_status="compiled", projection_operation_id=target.projection_operation_id, - supersedes_policy_id=str(target.predecessor_policy_id) if target.predecessor_policy_id else None, + supersedes_policy_id=str(target.predecessor_policy_id) + if target.predecessor_policy_id + else None, created_by=str(actor.actor_profile_id), ) async def _persist(self, command, actor, receipt, facts, authority, key): target = receipt.target - self.session.add(PostPolicyOperation( - operation_id=receipt.operation_id, kind=receipt.kind, idempotency_key=key, - project_id=str(target.proposal.project_id), guide_id=str(target.proposal.guide_id), - compilation_id=target.proposal.compilation_id, policy_id=str(target.policy_id), - upstream_approval_operation_id=target.upstream.operation_id, - target_json=target.model_dump(mode="json"), target_digest=target.digest, - request_json=command.model_dump(mode="json"), request_digest=facts.request_digest, - resource_context_json=facts.resource_json(), resource_context_digest=facts.digest, - receipt_json=receipt.model_dump(mode="json"), output_digest=facts.output_digest, - actor_profile_id=str(actor.actor_profile_id), identity_link_id=str(actor.identity_link_id), - admin_role_grant_id=authority.admin_role_grant_id, service_identity=authority.service_identity, - authorization_decision_event_id=str(authority.authorization_decision_event_id), - )) + self.session.add( + PostPolicyOperation( + operation_id=receipt.operation_id, + kind=receipt.kind, + idempotency_key=key, + project_id=str(target.proposal.project_id), + guide_id=str(target.proposal.guide_id), + compilation_id=target.proposal.compilation_id, + policy_id=str(target.policy_id), + upstream_approval_operation_id=target.upstream.operation_id, + target_json=target.model_dump(mode="json"), + target_digest=target.digest, + request_json=command.model_dump(mode="json"), + request_digest=facts.request_digest, + resource_context_json=facts.resource_json(), + resource_context_digest=facts.digest, + receipt_json=receipt.model_dump(mode="json"), + output_digest=facts.output_digest, + actor_profile_id=str(actor.actor_profile_id), + identity_link_id=str(actor.identity_link_id), + admin_role_grant_id=authority.admin_role_grant_id, + service_identity=authority.service_identity, + authorization_decision_event_id=str(authority.authorization_decision_event_id), + ) + ) await self.session.flush() - post_policy_derive_authorization_selector, - post_policy_human_authorization_selector, + + (post_policy_derive_authorization_selector,) + (post_policy_human_authorization_selector,) diff --git a/backend/app/modules/projects/post_submit_policy.py b/backend/app/modules/projects/post_submit_policy.py deleted file mode 100644 index 4db8fe36d..000000000 --- a/backend/app/modules/projects/post_submit_policy.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Compile and validate the single current post-submit policy contract.""" - -from __future__ import annotations -import json -from typing import Any -from uuid import UUID -from app.modules.checkers.api.post_submit_catalogue import ( - CompiledPostSubmitPolicy, - EmptyPostSubmitConfiguration, - PostSubmitPolicyEntry, - current_post_submit_catalogue, -) - -POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION = "post_submit_checker_policy_spec" -POST_SUBMIT_CHECKER_POLICY_SPEC_KEYS = { - "schema_version", - "project_id", - "guide_version", - "required_checkers", - "warning_checkers", - "blocking_severities", -} -DEFAULT_DURABLE_CHECKERS = [ - item.capability_id - for item in current_post_submit_catalogue().definitions - if item.platform_default -] -POST_SUBMIT_SEVERITY_ORDER = ("critical", "high", "medium", "low", "info") -PLATFORM_BLOCKING_SEVERITIES = ("critical", "high") -PLATFORM_BLOCKING_SEVERITY_SET = frozenset(PLATFORM_BLOCKING_SEVERITIES) - - -class PostSubmitCheckerCompilerError(ValueError): - """The supplied policy cannot compile into the current contract.""" - - -def build_project_post_submit_checker_spec( - *, - project_id: str, - guide_version: str, - required_checkers: list[str] | None = None, - warning_checkers: list[str] | None = None, - blocking_severities: list[str] | None = None, -) -> dict[str, Any]: - """Build constrained project additions without reclassifying platform defaults.""" - required = _canonical_checker_names(required_checkers if required_checkers is not None else []) - warning = _canonical_checker_names(warning_checkers if warning_checkers is not None else []) - _validate_checker_classifications( - required, warning, platform_default_checkers=DEFAULT_DURABLE_CHECKERS - ) - return { - "schema_version": POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, - "project_id": project_id, - "guide_version": guide_version, - "required_checkers": required, - "warning_checkers": warning, - "blocking_severities": _canonical_blocking_severities( - list(PLATFORM_BLOCKING_SEVERITIES) - if blocking_severities is None - else blocking_severities - ), - } - - -def compile_project_post_submit_checker_spec( - *, - project_id: str, - guide_version: str, - spec: dict[str, Any], -) -> CompiledPostSubmitPolicy: - """Compile project selections into the one canonical ordered policy body.""" - _validate_spec_shape(spec, project_id, guide_version) - required = _canonical_checker_names(spec["required_checkers"]) - warning = _canonical_checker_names(spec["warning_checkers"]) - severities = _canonical_blocking_severities(spec["blocking_severities"]) - _validate_checker_classifications( - required, warning, platform_default_checkers=DEFAULT_DURABLE_CHECKERS - ) - catalogue = current_post_submit_catalogue() - requested = set(required + warning) - selectable = {item.capability_id for item in catalogue.definitions if item.selectable} - if not requested.issubset(selectable): - raise PostSubmitCheckerCompilerError("post-submit project selection is unavailable") - entries = tuple( - PostSubmitPolicyEntry( - checker_id=item.capability_id, - definition_version=item.capability_version, - implementation_version=item.implementation_version, - classification=( - "platform_default" - if item.platform_default - else "project_required" - if item.capability_id in required - else "project_warning" - ), - configuration=item.validate_configuration(EmptyPostSubmitConfiguration()), - ) - for item in catalogue.definitions - if item.platform_default or item.capability_id in requested - ) - try: - policy = CompiledPostSubmitPolicy( - project_id=UUID(project_id), - guide_version=guide_version, - catalogue_manifest_sha256=catalogue.manifest_sha256, - entries=entries, - blocking_severities=tuple(severities), - ) - policy.validate_catalogue(catalogue) - except ValueError as exc: - raise PostSubmitCheckerCompilerError(str(exc)) from exc - return policy - - -def parse_locked_post_submit_checker_policy_body( - body: Any, - *, - project_id: str, - guide_version: str, - policy_hash: str, -) -> CompiledPostSubmitPolicy: - """Validate the saved schema, guide identity and body hash independently of live eligibility.""" - if not isinstance(body, dict): - raise ValueError("locked post-submit checker policy body is missing") - policy = CompiledPostSubmitPolicy.model_validate_json(json.dumps(body)) - if str(policy.project_id) != project_id or policy.guide_version != guide_version: - raise ValueError("locked post-submit checker policy guide context mismatch") - if policy.policy_hash != policy_hash: - raise ValueError("locked post-submit checker policy hash is invalid") - return policy - - -def _validate_spec_shape( - spec: dict[str, Any], - project_id: str, - guide_version: str, -) -> None: - """Validate the constrained post-submit spec envelope.""" - if not isinstance(spec, dict): - raise PostSubmitCheckerCompilerError("post-submit checker spec shape is invalid") - if set(spec) != POST_SUBMIT_CHECKER_POLICY_SPEC_KEYS: - raise PostSubmitCheckerCompilerError("post-submit checker spec shape is invalid") - if spec.get("schema_version") != POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION: - raise PostSubmitCheckerCompilerError("post-submit checker spec schema version is invalid") - if spec.get("project_id") != project_id or spec.get("guide_version") != guide_version: - raise PostSubmitCheckerCompilerError("post-submit checker spec guide context mismatch") - for field_name in ("required_checkers", "warning_checkers", "blocking_severities"): - if not _string_list(spec.get(field_name)): - raise PostSubmitCheckerCompilerError( - f"post-submit checker spec {field_name} is invalid" - ) - - -def _validate_checker_classifications( - required_checkers: list[str], - warning_checkers: list[str], - *, - platform_default_checkers: list[str], -) -> None: - """Reject duplicate, contradictory, or weakening checker classifications.""" - duplicate_required = _duplicates(required_checkers) - duplicate_warning = _duplicates(warning_checkers) - if duplicate_required or duplicate_warning: - raise PostSubmitCheckerCompilerError( - "post-submit checker spec contains duplicate checker names" - ) - conflicting = set(required_checkers).intersection(warning_checkers) - if conflicting: - raise PostSubmitCheckerCompilerError( - "post-submit checker spec contains conflicting checker classifications" - ) - weakened_defaults = sorted(set(warning_checkers).intersection(platform_default_checkers)) - if weakened_defaults: - raise PostSubmitCheckerCompilerError( - "post-submit checker spec cannot mark default checkers as warning-only" - ) - - -def _canonical_checker_names(checker_names: list[str]) -> list[str]: - """Return stable checker names while rejecting malformed values.""" - if not _string_list(checker_names): - raise PostSubmitCheckerCompilerError("post-submit checker names must be strings") - for checker_name in checker_names: - if checker_name.strip() != checker_name or not checker_name: - raise PostSubmitCheckerCompilerError("post-submit checker names must be canonical") - return sorted(checker_names) - - -def _canonical_blocking_severities(severities: list[str]) -> list[str]: - """Return canonical blocking severities without weakening platform defaults.""" - if not _string_list(severities): - raise PostSubmitCheckerCompilerError("post-submit blocking severities must be strings") - unknown = sorted(set(severities).difference(POST_SUBMIT_SEVERITY_ORDER)) - if unknown: - raise PostSubmitCheckerCompilerError("post-submit checker spec contains unknown severities") - if not PLATFORM_BLOCKING_SEVERITY_SET.issubset(severities): - raise PostSubmitCheckerCompilerError( - "post-submit checker spec weakens platform blocking severities" - ) - return [severity for severity in POST_SUBMIT_SEVERITY_ORDER if severity in set(severities)] - - -def _string_list(value: Any) -> bool: - """Return whether a value is a JSON-list-shaped string list.""" - return isinstance(value, list) and all(isinstance(item, str) for item in value) - - -def _duplicates(values: list[str]) -> set[str]: - """Return duplicate values from a sequence.""" - seen: set[str] = set() - duplicates: set[str] = set() - for value in values: - if value in seen: - duplicates.add(value) - seen.add(value) - return duplicates diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 0ce359314..bc030fadc 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -1187,10 +1187,18 @@ def validate_activation_ready( ) if not isinstance(pre_submit_checker_policy.compiled_bundle, dict): raise GuideActivationBlocked("pre-submit checker compiled bundle must be an object") - if ( - self._hash_canonical_json(pre_submit_checker_policy.compiled_bundle) - != pre_submit_checker_policy.compiled_bundle_hash - ): + from app.modules.checkers.api.external_policy import ( + validate_stored_external_pre_policy, + ) + try: + compiled_pre = validate_stored_external_pre_policy( + pre_submit_checker_policy.compiled_bundle + ) + except ValueError as exc: + raise GuideActivationBlocked( + "external pre-submit checker policy is required" + ) from exc + if compiled_pre.policy_sha256 != pre_submit_checker_policy.compiled_bundle_hash: raise GuideActivationBlocked("pre-submit checker compiled bundle hash mismatch") from .post_policy.custody import validate_activation_post_policy try: diff --git a/backend/app/modules/tasks/api/submitted_bundle.py b/backend/app/modules/tasks/api/submitted_bundle.py index e189c555f..dd8d149e9 100644 --- a/backend/app/modules/tasks/api/submitted_bundle.py +++ b/backend/app/modules/tasks/api/submitted_bundle.py @@ -31,6 +31,8 @@ class SubmittedPolicyContext: post_policy_id: UUID post_policy_version: str post_policy_hash: str + selected_post_policy_hash: str | None + selected_post_policy_body: dict | None review_policy_id: UUID review_generation: int review_hash: str diff --git a/backend/app/modules/tasks/evaluation_delivery.py b/backend/app/modules/tasks/evaluation_delivery.py index 5cafcb2e3..0406a33e6 100644 --- a/backend/app/modules/tasks/evaluation_delivery.py +++ b/backend/app/modules/tasks/evaluation_delivery.py @@ -11,8 +11,8 @@ from app.modules.checkers.api.execution import ( CheckerExecutionUnavailable, EvaluationCoordinationPort, REQUEST_EVENT, ) -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, PostSubmissionExecutionPort, +from app.modules.checkers.api.evaluation_contract import ( + PostSubmissionExecutionRequest, ) from app.modules.outbox.api import ( CommittedInvocationPort, HandlerOutcome, OutboxEventEnvelope, @@ -31,7 +31,7 @@ class EvaluationDeliveryRequestReader: def __init__(self, session: AsyncSession, evaluations: EvaluationCoordinationPort): self._session, self._evaluations = session, evaluations - async def read(self, envelope: OutboxEventEnvelope) -> PostSubmissionEvaluationRequest: + async def read(self, envelope: OutboxEventEnvelope) -> PostSubmissionExecutionRequest: """Select all owner identifiers together, without creating or repairing rows.""" retry = await self._evaluations.read_retry_delivery( project_id=envelope.claim.project_id, @@ -98,7 +98,7 @@ def __init__( self, sessions: async_sessionmaker[AsyncSession], *, observer: CommittedInvocationPort, evaluations: Callable[[AsyncSession], EvaluationCoordinationPort], - executor: Callable[[OutboxEventEnvelope, PostSubmissionEvaluationRequest], PostSubmissionExecutionPort], + executor: Callable[[OutboxEventEnvelope, PostSubmissionExecutionRequest], object], ): self._sessions, self._observer = sessions, observer self._evaluations, self._executor = evaluations, executor diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index d1937666c..829117b93 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -540,6 +540,13 @@ class Submission(Base): "and artifact_binding_id is not null and artifact_content_id is not null)", name="artifact_lineage_shape", ), + CheckConstraint( + "(selected_post_submit_policy_sha256 is null and " + "selected_post_submit_policy_body is null) or " + "(selected_post_submit_policy_sha256 ~ '^sha256:[0-9a-f]{64}$' and " + "jsonb_typeof(selected_post_submit_policy_body)='object')", + name="selected_post_submit_policy", + ), Index( "ix_submissions_locked_source_snapshot", "locked_guide_source_snapshot_id", @@ -592,6 +599,8 @@ class Submission(Base): ) locked_post_submit_checker_policy_hash: Mapped[str] = mapped_column(String(71), nullable=False) locked_post_submit_checker_policy_body: Mapped[dict] = mapped_column(JSON, nullable=False) + selected_post_submit_policy_sha256: Mapped[str | None] = mapped_column(String(71)) + selected_post_submit_policy_body: Mapped[dict | None] = mapped_column(JSONB) locked_review_policy_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) locked_review_policy_generation: Mapped[int] = mapped_column(Integer, nullable=False) locked_review_policy_hash: Mapped[str] = mapped_column(String(71), nullable=False) 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 c40eb803b..45665f0a8 100644 --- a/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py +++ b/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py @@ -6,12 +6,36 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.modules.checkers.api.execution import CheckerExecutionUnavailable -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationRequest +from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest 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 +def _context_matches(request: PostSubmissionExecutionRequest, context: object) -> bool: + """Compare selected request lineage with exact retained TASK facts.""" + observed = asdict(context) + return request.expected_context.model_dump() == { + "guide_version": observed["guide_version"], + "source_id": observed["source_id"], + "source_hash": observed["source_hash"], + "effective_policy_id": observed["effective_policy_id"], + "effective_policy_hash": observed["effective_policy_hash"], + "pre_policy_id": observed["pre_policy_id"], + "pre_policy_hash": observed["pre_policy_hash"], + "post_policy_id": observed["post_policy_id"], + "post_policy_version": observed["post_policy_version"], + "post_policy_hash": observed["post_policy_hash"], + "review_policy_id": observed["review_policy_id"], + "review_generation": observed["review_generation"], + "review_hash": observed["review_hash"], + "revision_policy_id": observed["revision_policy_id"], + "revision_generation": observed["revision_generation"], + "revision_hash": observed["revision_hash"], + } and observed["selected_post_policy_hash"] == request.expected_context.post_policy_hash \ + and observed["selected_post_policy_body"] == request.policy.model_dump(mode="json") + + class TaskEvaluationGuard: """Lock current TASK scope without granting execution or changing product state.""" @@ -19,7 +43,7 @@ def __init__(self, session: AsyncSession): self._repository = TaskRepository(session) self._submitted = SubmittedBundleReader(session) - async def lock_evaluation_scope(self, request: PostSubmissionEvaluationRequest) -> bool: + 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) if task is None: @@ -52,7 +76,7 @@ async def lock_evaluation_scope(self, request: PostSubmissionEvaluationRequest) and facts.contributor_id == UUID(assignment.contributor_id) and facts.binding_id == request.binding_id and facts.content_id == request.content_id - and asdict(facts.context) == request.expected_context.model_dump() + and _context_matches(request, facts.context) ): raise CheckerExecutionUnavailable("checker_reservation_scope_unavailable") if task.status in {"accepted", "rejected"}: @@ -63,7 +87,7 @@ async def lock_evaluation_scope(self, request: PostSubmissionEvaluationRequest) raise CheckerExecutionUnavailable("checker_reservation_scope_unavailable") return True - async def lock_terminal_retry_scope(self, request: PostSubmissionEvaluationRequest) -> None: + async def lock_terminal_retry_scope(self, request: PostSubmissionExecutionRequest) -> None: """New recovery must target an active and unrouted evaluation-pending task.""" if not await self.lock_evaluation_scope(request): raise CheckerExecutionUnavailable("checker_retry_scope_unavailable") @@ -71,7 +95,7 @@ async def lock_terminal_retry_scope(self, request: PostSubmissionEvaluationReque if task is None or task.status != "evaluation_pending": raise CheckerExecutionUnavailable("checker_retry_scope_unavailable") - async def lock_retry_replay_scope(self, request: PostSubmissionEvaluationRequest) -> None: + async def lock_retry_replay_scope(self, request: PostSubmissionExecutionRequest) -> None: """Replay retains exact historical owners even after accepted completion.""" task = await self._repository.lock_project_task(request.project_id, request.task_id) if task is None: @@ -103,6 +127,6 @@ async def lock_retry_replay_scope(self, request: PostSubmissionEvaluationRequest and facts.contributor_id == UUID(assignment.contributor_id) and facts.binding_id == request.binding_id and facts.content_id == request.content_id - and asdict(facts.context) == request.expected_context.model_dump() + and _context_matches(request, facts.context) ): raise CheckerExecutionUnavailable("checker_retry_replay_unavailable") diff --git a/backend/app/modules/tasks/repository.py b/backend/app/modules/tasks/repository.py index 7bc2b5cc9..e64deda54 100644 --- a/backend/app/modules/tasks/repository.py +++ b/backend/app/modules/tasks/repository.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from app.modules.tasks.api.transition_audit import TaskPolicyLineage -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy +from app.modules.checkers.api.external_policy import validate_stored_external_post_policy from uuid import UUID from sqlalchemy import Row, Select, and_, or_, select, tuple_ @@ -16,10 +16,15 @@ from app.modules.audit.repository import AuditRepository from app.modules.tasks.api import ( - AuditTaskEvidence, AuditTaskEvidencePage, AuditTaskEvidenceRequest, - TaskEvidenceCursor, TaskEvidenceInvalid, - ContributorTaskDetail, ContributorTaskDetailRequest, - ManagementTaskDetail, ManagementTaskDetailRequest, + AuditTaskEvidence, + AuditTaskEvidencePage, + AuditTaskEvidenceRequest, + TaskEvidenceCursor, + TaskEvidenceInvalid, + ContributorTaskDetail, + ContributorTaskDetailRequest, + ManagementTaskDetail, + ManagementTaskDetailRequest, ManagementTaskPage, ManagementTaskSummary, OperationalTaskPage, @@ -39,7 +44,8 @@ TaskSubmissionContextUnavailable, ) from app.modules.tasks.api.assignment_invalidation import ( - AssignmentInvalidationTarget, AssignmentInvalidationTargetsRequest, + AssignmentInvalidationTarget, + AssignmentInvalidationTargetsRequest, AssignmentInvalidationTargetsPage, ) from app.modules.tasks.models import ( @@ -54,9 +60,14 @@ def _unassigned_ready_task(): """Shared ready visibility for queue discovery and contributor detail.""" task = WorkstreamTask - active_assignment = select(TaskAssignment.id).where( - TaskAssignment.task_id == task.id, TaskAssignment.status == "active", - ).exists() + active_assignment = ( + select(TaskAssignment.id) + .where( + TaskAssignment.task_id == task.id, + TaskAssignment.status == "active", + ) + .exists() + ) return and_(task.status == "ready", task.assigned_to.is_(None), ~active_assignment) @@ -64,10 +75,20 @@ def _task_detail_columns(): """Fixed common detail projection; never select a task ORM entity.""" task = WorkstreamTask return ( - task.id.label("task_id"), task.project_id, task.title, task.description, - task.task_type, task.difficulty, task.skill_tags, task.estimated_time_minutes, - task.status, task.acceptance_criteria, task.rejection_criteria, - task.deadline_at, task.created_at, task.updated_at, + task.id.label("task_id"), + task.project_id, + task.title, + task.description, + task.task_type, + task.difficulty, + task.skill_tags, + task.estimated_time_minutes, + task.status, + task.acceptance_criteria, + task.rejection_criteria, + task.deadline_at, + task.created_at, + task.updated_at, ) @@ -127,20 +148,33 @@ def response_terms(value) -> TaskContributionTerms: return mapped async def read_assignment_invalidation_targets_page( - self, request: AssignmentInvalidationTargetsRequest, + self, + request: AssignmentInvalidationTargetsRequest, ) -> AssignmentInvalidationTargetsPage: """Nonlocking fixed projection; AUTH owns serialization and the root transaction.""" request = AssignmentInvalidationTargetsRequest.model_validate(request.model_dump()) assignment, task = TaskAssignment, WorkstreamTask - statement = select( - assignment.id, assignment.task_id, assignment.project_id, assignment.contributor_id, - ).join(task, and_(task.id == assignment.task_id, task.project_id == assignment.project_id)).where( - assignment.contributor_id == str(request.contributor_id), - assignment.status == "active", assignment.released_at.is_(None), - assignment.assigned_at <= request.invalidated_at, - task.status.in_(("claimed", "in_progress")), task.assigned_to == assignment.contributor_id, - assignment.submitter_contribution_policy_version_id == task.locked_contribution_policy_version_id, - ~select(Submission.id).where(Submission.task_id == task.id).exists(), + statement = ( + select( + assignment.id, + assignment.task_id, + assignment.project_id, + assignment.contributor_id, + ) + .join( + task, and_(task.id == assignment.task_id, task.project_id == assignment.project_id) + ) + .where( + assignment.contributor_id == str(request.contributor_id), + assignment.status == "active", + assignment.released_at.is_(None), + assignment.assigned_at <= request.invalidated_at, + task.status.in_(("claimed", "in_progress")), + task.assigned_to == assignment.contributor_id, + assignment.submitter_contribution_policy_version_id + == task.locked_contribution_policy_version_id, + ~select(Submission.id).where(Submission.task_id == task.id).exists(), + ) ) if request.project_id is not None: statement = statement.where(assignment.project_id == str(request.project_id)) @@ -149,11 +183,16 @@ async def read_assignment_invalidation_targets_page( with self._session.no_autoflush: rows = (await self._session.execute(statement.order_by(assignment.id).limit(101))).all() return AssignmentInvalidationTargetsPage( - items=tuple(AssignmentInvalidationTarget( - project_id=UUID(row.project_id), task_id=UUID(row.task_id), assignment_id=UUID(row.id), - contributor_id=UUID(row.contributor_id), - authority_invalidation_event_id=request.invalidation_event_id, - ) for row in rows[:100]), + items=tuple( + AssignmentInvalidationTarget( + project_id=UUID(row.project_id), + task_id=UUID(row.task_id), + assignment_id=UUID(row.id), + contributor_id=UUID(row.contributor_id), + authority_invalidation_event_id=request.invalidation_event_id, + ) + for row in rows[:100] + ), next_after=UUID(rows[99].id) if len(rows) > 100 else None, ) @@ -165,8 +204,14 @@ async def read_ready_tasks(self, request: TaskQueueRequest) -> ReadyTaskPage: """ task = WorkstreamTask statement = select( - task.id, task.project_id, task.title, task.task_type, task.difficulty, - task.skill_tags, task.estimated_time_minutes, task.created_at, + task.id, + task.project_id, + task.title, + task.task_type, + task.difficulty, + task.skill_tags, + task.estimated_time_minutes, + task.created_at, task.locked_contribution_policy_version_id, ).where(_unassigned_ready_task()) rows, continuation = await self._read_task_queue_rows(request, statement) @@ -176,9 +221,13 @@ async def read_ready_tasks(self, request: TaskQueueRequest) -> ReadyTaskPage: ) items = tuple( ReadyTaskSummary( - task_id=UUID(row.id), project_id=UUID(row.project_id), title=row.title, - task_type=row.task_type, difficulty=row.difficulty, - skill_tags=tuple(row.skill_tags), estimated_time_minutes=row.estimated_time_minutes, + task_id=UUID(row.id), + project_id=UUID(row.project_id), + title=row.title, + task_type=row.task_type, + difficulty=row.difficulty, + skill_tags=tuple(row.skill_tags), + estimated_time_minutes=row.estimated_time_minutes, created_at=row.created_at, compensation=compensation[row.locked_contribution_policy_version_id], ) @@ -189,33 +238,69 @@ async def read_ready_tasks(self, request: TaskQueueRequest) -> ReadyTaskPage: async def read_management_tasks(self, request: TaskQueueRequest) -> ManagementTaskPage: """Return all project tasks with fixed planning fields and no work content.""" task = WorkstreamTask - rows, cursor = await self._read_task_queue_rows(request, select( - task.id, task.project_id, task.title, task.task_type, task.difficulty, - task.skill_tags, task.estimated_time_minutes, task.status, task.deadline_at, - task.created_at, task.updated_at, - )) - items = tuple(ManagementTaskSummary( - task_id=UUID(row.id), project_id=UUID(row.project_id), title=row.title, - task_type=row.task_type, difficulty=row.difficulty, skill_tags=tuple(row.skill_tags), - estimated_time_minutes=row.estimated_time_minutes, status=row.status, - deadline_at=row.deadline_at, created_at=row.created_at, updated_at=row.updated_at, - ) for row in rows) + rows, cursor = await self._read_task_queue_rows( + request, + select( + task.id, + task.project_id, + task.title, + task.task_type, + task.difficulty, + task.skill_tags, + task.estimated_time_minutes, + task.status, + task.deadline_at, + task.created_at, + task.updated_at, + ), + ) + items = tuple( + ManagementTaskSummary( + task_id=UUID(row.id), + project_id=UUID(row.project_id), + title=row.title, + task_type=row.task_type, + difficulty=row.difficulty, + skill_tags=tuple(row.skill_tags), + estimated_time_minutes=row.estimated_time_minutes, + status=row.status, + deadline_at=row.deadline_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in rows + ) return ManagementTaskPage(request.project_id, items, cursor) async def read_operational_tasks(self, request: TaskQueueRequest) -> OperationalTaskPage: """Select status-only columns, without loading private task content.""" task = WorkstreamTask - rows, cursor = await self._read_task_queue_rows(request, select( - task.id, task.project_id, task.status, task.created_at, task.updated_at, - )) - items = tuple(OperationalTaskSummary( - task_id=UUID(row.id), project_id=UUID(row.project_id), status=row.status, - created_at=row.created_at, updated_at=row.updated_at, - ) for row in rows) + rows, cursor = await self._read_task_queue_rows( + request, + select( + task.id, + task.project_id, + task.status, + task.created_at, + task.updated_at, + ), + ) + items = tuple( + OperationalTaskSummary( + task_id=UUID(row.id), + project_id=UUID(row.project_id), + status=row.status, + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in rows + ) return OperationalTaskPage(request.project_id, items, cursor) async def _read_task_queue_rows( - self, request: TaskQueueRequest, statement: Select, + self, + request: TaskQueueRequest, + statement: Select, ) -> tuple[Sequence[Row], TaskQueueCursor | None]: """Apply one exact project and live position before the bounded query. @@ -228,34 +313,49 @@ async def _read_task_queue_rows( statement = statement.where(task.project_id == str(request.project_id)) if request.after is not None: statement = statement.where( - tuple_(task.created_at, task.id) > tuple_( - request.after.created_at, request.after.task_id, + tuple_(task.created_at, task.id) + > tuple_( + request.after.created_at, + request.after.task_id, ), ) statement = statement.order_by(task.created_at, task.id).limit(request.limit + 1) with self._session.no_autoflush: rows = (await self._session.execute(statement)).all() continuation = ( - TaskQueueCursor(request.project_id, rows[request.limit - 1].created_at, UUID(rows[request.limit - 1].id)) - if len(rows) > request.limit else None + TaskQueueCursor( + request.project_id, + rows[request.limit - 1].created_at, + UUID(rows[request.limit - 1].id), + ) + if len(rows) > request.limit + else None ) - return rows[:request.limit], continuation + return rows[: request.limit], continuation - async def read_contributor_task_detail(self, request: ContributorTaskDetailRequest) -> ContributorTaskDetail | None: + async def read_contributor_task_detail( + self, request: ContributorTaskDetailRequest + ) -> ContributorTaskDetail | None: """Read visible work facts in one scoped query; caller supplies authority.""" if type(request) is not ContributorTaskDetailRequest: raise ValueError("task detail request is invalid") task, assignment = WorkstreamTask, TaskAssignment - own_assignment = select(assignment.id).where( - assignment.task_id == task.id, - assignment.project_id == task.project_id, - assignment.status == "active", - assignment.contributor_id == str(request.contributor_id), - ).exists() - statement = select(*_task_detail_columns()).where(or_( - _unassigned_ready_task(), - and_(task.assigned_to == str(request.contributor_id), own_assignment), - )) + own_assignment = ( + select(assignment.id) + .where( + assignment.task_id == task.id, + assignment.project_id == task.project_id, + assignment.status == "active", + assignment.contributor_id == str(request.contributor_id), + ) + .exists() + ) + statement = select(*_task_detail_columns()).where( + or_( + _unassigned_ready_task(), + and_(task.assigned_to == str(request.contributor_id), own_assignment), + ) + ) statement = statement.add_columns(task.locked_contribution_policy_version_id) values = await self._read_task_detail_values(request, statement) if values is None: @@ -269,36 +369,53 @@ async def contributor_task_visible(self, request: ContributorTaskDetailRequest) if type(request) is not ContributorTaskDetailRequest: raise ValueError("task detail request is invalid") task, assignment = WorkstreamTask, TaskAssignment - own_assignment = select(assignment.id).where( - assignment.task_id == task.id, - assignment.project_id == task.project_id, - assignment.status == "active", - assignment.contributor_id == str(request.contributor_id), - ).exists() - statement = select(task.id).where(or_( - _unassigned_ready_task(), - and_(task.assigned_to == str(request.contributor_id), own_assignment), - )) + own_assignment = ( + select(assignment.id) + .where( + assignment.task_id == task.id, + assignment.project_id == task.project_id, + assignment.status == "active", + assignment.contributor_id == str(request.contributor_id), + ) + .exists() + ) + statement = select(task.id).where( + or_( + _unassigned_ready_task(), + and_(task.assigned_to == str(request.contributor_id), own_assignment), + ) + ) statement = statement.where( - task.project_id == str(request.project_id), task.id == str(request.task_id), + task.project_id == str(request.project_id), + task.id == str(request.task_id), ) with self._session.no_autoflush: return await self._session.scalar(statement) is not None - async def read_management_task_detail(self, request: ManagementTaskDetailRequest) -> ManagementTaskDetail | None: + async def read_management_task_detail( + self, request: ManagementTaskDetailRequest + ) -> ManagementTaskDetail | None: """Read exact-project management facts without broad ORM loading.""" if type(request) is not ManagementTaskDetailRequest: raise ValueError("task detail request is invalid") task = WorkstreamTask statement = select( - *_task_detail_columns(), task.source_type, task.source_ref, task.source_payload_hash, - task.import_batch_id, task.external_task_id, task.created_by, task.assigned_to, + *_task_detail_columns(), + task.source_type, + task.source_ref, + task.source_payload_hash, + task.import_batch_id, + task.external_task_id, + task.created_by, + task.assigned_to, ) values = await self._read_task_detail_values(request, statement) return ManagementTaskDetail(**values) if values is not None else None async def _read_task_detail_values( - self, request: ContributorTaskDetailRequest | ManagementTaskDetailRequest, statement: Select, + self, + request: ContributorTaskDetailRequest | ManagementTaskDetailRequest, + statement: Select, ) -> dict | None: """Keep exact scope, one query and transaction behavior common to both reads.""" statement = statement.where( @@ -310,16 +427,21 @@ async def _read_task_detail_values( if row is None: return None return dict(row) | { - "task_id": UUID(row["task_id"]), "project_id": UUID(row["project_id"]), + "task_id": UUID(row["task_id"]), + "project_id": UUID(row["project_id"]), "skill_tags": tuple(row["skill_tags"]), } - async def read_audit_task_evidence(self, request: AuditTaskEvidenceRequest) -> AuditTaskEvidencePage | None: + async def read_audit_task_evidence( + self, request: AuditTaskEvidenceRequest + ) -> AuditTaskEvidencePage | None: """Map one AUDIT-owned scoped statement into immutable TASK evidence.""" if type(request) is not AuditTaskEvidenceRequest: raise ValueError("task evidence request is invalid") rows = await self._audit_repository.read_task_evidence_rows( - request.project_id, request.task_id, request.limit, + request.project_id, + request.task_id, + request.limit, request.after.created_at if request.after else None, request.after.event_id if request.after else None, ) @@ -327,10 +449,17 @@ async def read_audit_task_evidence(self, request: AuditTaskEvidenceRequest) -> A return None if rows[0].event_id is None: return AuditTaskEvidencePage(request.project_id, request.task_id, (), None) - items = tuple(self._task_evidence_item(row, request) for row in rows[:request.limit]) - cursor = TaskEvidenceCursor( - request.project_id, request.task_id, items[-1].created_at, items[-1].event_id, - ) if len(rows) > request.limit else None + items = tuple(self._task_evidence_item(row, request) for row in rows[: request.limit]) + cursor = ( + TaskEvidenceCursor( + request.project_id, + request.task_id, + items[-1].created_at, + items[-1].event_id, + ) + if len(rows) > request.limit + else None + ) return AuditTaskEvidencePage(request.project_id, request.task_id, items, cursor) @staticmethod @@ -338,7 +467,15 @@ def _task_evidence_item(row: Row, request: AuditTaskEvidenceRequest) -> AuditTas """Require exact transition references without exposing stored diagnostics.""" assignment_id = decision_id = None try: - if row.event_type in {"TaskCreated", "TaskScreened", "TaskReleased", "TaskClaimed", "TaskStarted", "TaskStartOverridden", "TaskAssignmentAuthorityRevoked"}: + if row.event_type in { + "TaskCreated", + "TaskScreened", + "TaskReleased", + "TaskClaimed", + "TaskStarted", + "TaskStartOverridden", + "TaskAssignmentAuthorityRevoked", + }: if ( UUID(row.reference_project_id) != request.project_id or UUID(row.reference_task_id) != request.task_id @@ -351,8 +488,14 @@ def _task_evidence_item(row: Row, request: AuditTaskEvidenceRequest) -> AuditTas assignment_id = UUID(row.assignment_id) decision_id = UUID(row.authorization_decision_id) return AuditTaskEvidence( - UUID(row.event_id), row.event_type, row.from_status, row.to_status, - row.actor_id, row.created_at, assignment_id, decision_id, + UUID(row.event_id), + row.event_type, + row.from_status, + row.to_status, + row.actor_id, + row.created_at, + assignment_id, + decision_id, ) except (TypeError, ValueError, AttributeError): raise TaskEvidenceInvalid("task audit evidence is invalid") from None @@ -389,18 +532,19 @@ async def get_task( statement = select(WorkstreamTask).where(WorkstreamTask.id == task_id) if for_update: statement = statement.with_for_update() - return await self._session.scalar( - statement.execution_options(populate_existing=True) - ) + return await self._session.scalar(statement.execution_options(populate_existing=True)) async def lock_project_task(self, project_id: UUID, task_id: UUID) -> WorkstreamTask | None: """Conceal foreign tasks before taking their lock or resolving policy custody.""" with self._session.no_autoflush: return await self._session.scalar( - select(WorkstreamTask).where( + select(WorkstreamTask) + .where( WorkstreamTask.project_id == str(project_id), WorkstreamTask.id == str(task_id), - ).with_for_update().execution_options(populate_existing=True) + ) + .with_for_update() + .execution_options(populate_existing=True) ) async def lock_accepted_assignment( @@ -413,11 +557,14 @@ async def lock_accepted_assignment( """Lock only the requested project/task assignment after its Task lock.""" with self._session.no_autoflush: return await self._session.scalar( - select(TaskAssignment).where( + select(TaskAssignment) + .where( TaskAssignment.id == str(assignment_id), TaskAssignment.task_id == str(task_id), TaskAssignment.project_id == str(project_id), - ).with_for_update().execution_options(populate_existing=True) + ) + .with_for_update() + .execution_options(populate_existing=True) ) async def read_routing_manifest( @@ -476,27 +623,32 @@ async def get_active_assignment( ) if for_update: statement = statement.with_for_update() - result = await self._session.execute( - statement.execution_options(populate_existing=True) - ) + result = await self._session.execute(statement.execution_options(populate_existing=True)) return result.scalar_one_or_none() async def lock_invalidation_assignment(self, target) -> TaskAssignment | None: """Lock the event's original assignment, never a replacement active claim.""" return await self._session.scalar( - select(TaskAssignment).where( + select(TaskAssignment) + .where( TaskAssignment.id == str(target.assignment_id), TaskAssignment.task_id == str(target.task_id), TaskAssignment.project_id == str(target.project_id), TaskAssignment.contributor_id == str(target.contributor_id), - ).with_for_update().execution_options(populate_existing=True) + ) + .with_for_update() + .execution_options(populate_existing=True) ) async def has_submission(self, task_id: UUID) -> bool: """Any retained Submission excludes ordinary pre-submit release.""" - return bool(await self._session.scalar(select( - select(Submission.id).where(Submission.task_id == str(task_id)).exists(), - ))) + return bool( + await self._session.scalar( + select( + select(Submission.id).where(Submission.task_id == str(task_id)).exists(), + ) + ) + ) async def select_submission_context( self, @@ -596,12 +748,12 @@ async def select_submission_context( for key in TaskPolicyLineage.model_fields for value in (getattr(task, key),) })) - post_policy = CompiledPostSubmitPolicy.model_validate_json( - json.dumps(task.locked_post_submit_checker_policy_body), - ) - if (post_policy.policy_hash != locked_policy.locked_post_submit_checker_policy_hash - or post_policy.project_id != UUID(task.project_id) - or post_policy.guide_version != locked_policy.locked_guide_version): + post_body = task.locked_post_submit_checker_policy_body + selected_post = validate_stored_external_post_policy(post_body) + if ( + selected_post.policy_sha256 + != locked_policy.locked_post_submit_checker_policy_hash + ): raise ValueError("task post-submit policy body differs from locked identity") locked_project_context = TaskLockedProjectContextReferences( project_id=UUID(task.project_id), @@ -637,7 +789,9 @@ async def select_submission_context( locked_policy=locked_policy, acceptance_criteria=task.acceptance_criteria, submitter_contribution_policy_version_id=assignment.submitter_contribution_policy_version_id, - checker_remediation_manifest_id=remediation.id if remediation is not None else None, + checker_remediation_manifest_id=( + UUID(str(remediation.id)) if remediation is not None else None + ), ) return facts, remediation except (TypeError, ValueError) as exc: @@ -645,6 +799,7 @@ async def select_submission_context( "task_submission_context_invalid" ) from exc + async def add_submission(self, submission: Submission) -> Submission: """Persist a submission packet and its evidence items. @@ -659,7 +814,6 @@ async def add_submission(self, submission: Submission) -> Submission: await self._session.refresh(submission) return submission - async def get_latest_submission_for_task( self, task_id: str, @@ -708,7 +862,6 @@ async def lock_exact_submission_for_task( .execution_options(populate_existing=True) ) - async def add_audit_event(self, event: AuditEvent) -> AuditEvent: """Persist an audit event. diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 23df13499..d7b3e1c91 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -from collections.abc import Callable from dataclasses import dataclass from typing import Any from uuid import UUID @@ -11,14 +10,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from pydantic import ValidationError -from app.modules.checkers.api.pre_submit import ( - EffectivePreSubmissionPlanningPort, EffectivePreSubmissionPlanLineage, -) from app.modules.tasks.api import TaskSubmissionContextFacts -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy, PostSubmitCatalogue +from app.modules.checkers.api.external_policy import ( + LockedExternalCheckerPolicies, + LockedExternalPostSubmitPolicy, + validate_stored_external_post_policy, +) from app.modules.projects.api.locked_policy import ( - ProjectLockedPolicyContextFacts, ProjectLockedPolicyContextPort, - ProjectLockedPolicyContextRequest, ProjectLockedPolicyContextUnavailable, + ProjectLockedPolicyContextFacts, + ProjectLockedPolicyContextPort, + ProjectLockedPolicyContextRequest, + ProjectLockedPolicyContextUnavailable, ) from app.modules.tasks.lifecycle import ( InvalidTaskTransition, @@ -135,8 +137,10 @@ class TaskService: """Coordinates task lifecycle rules, assignment, and audit writes.""" def __init__( - self, session: AsyncSession, *, project_contexts: ProjectLockedPolicyContextPort, - pre_submit_planner: EffectivePreSubmissionPlanningPort, post_submit_catalogue: Callable[[], PostSubmitCatalogue], + self, + session: AsyncSession, + *, + project_contexts: ProjectLockedPolicyContextPort, ) -> None: """Create a service instance bound to one database session. @@ -144,20 +148,25 @@ def __init__( session: Async SQLAlchemy session for the current request. """ self._project_contexts = project_contexts - self._pre_submit_planner = pre_submit_planner - self._post_submit_catalogue = post_submit_catalogue self._session = session self._repo = TaskRepository(session) async def read_management_task_submission_requirements( - self, project_id: UUID, task_id: UUID, + self, + project_id: UUID, + task_id: UUID, ) -> ManagementTaskSubmissionRequirements: """Read hidden management requirements in the caller's authorized transaction.""" task, context = await self._read_locked_context(project_id, task_id) - return ManagementTaskSubmissionRequirements(**self._submission_requirement_values(task, context)) + return ManagementTaskSubmissionRequirements( + **self._submission_requirement_values(task, context) + ) async def read_contributor_task_submission_requirements( - self, project_id: UUID, task_id: UUID, contributor_id: UUID, + self, + project_id: UUID, + task_id: UUID, + contributor_id: UUID, ) -> ContributorTaskSubmissionRequirements: """Lock TASK, conceal invisible work, then validate historical policy custody.""" request = ContributorTaskDetailRequest(project_id, task_id, contributor_id) @@ -179,14 +188,15 @@ async def _lock_scoped_task(self, project_id: UUID, task_id: UUID) -> Workstream return task async def _read_locked_context( - self, project_id: UUID, task_id: UUID, + self, + project_id: UUID, + task_id: UUID, ) -> tuple[WorkstreamTask, LockedTaskContext]: """Resolve historical PROJECTS custody only after the shared TASK lock.""" with self._session.no_autoflush: task = await self._lock_scoped_task(project_id, task_id) return task, await self._load_locked_task_context(task) - async def _load_active_policy_context(self, project_id: str) -> ProjectLockedPolicyContextFacts: """Resolve exact activated custody and verify both installed checker plans.""" try: @@ -197,39 +207,38 @@ async def _load_active_policy_context(self, project_id: str) -> ProjectLockedPol raise TaskProjectNotReady("active guide policy context is unavailable") from exc def _validate_installed_plans(self, facts: ProjectLockedPolicyContextFacts) -> None: - """Check deployment capability before new readiness, without executing checks.""" + """Require newly ready tasks to use the activated selected-image policy.""" try: - self._pre_submit_planner.compile_effective_plan( - lineage=EffectivePreSubmissionPlanLineage( - project_id=facts.project_id, guide_id=facts.guide_id, - guide_version=facts.guide_version, source_snapshot_id=facts.source_snapshot_id, - source_snapshot_hash=facts.source_snapshot_hash, - effective_policy_id=facts.effective_policy_id, - effective_policy_hash=facts.effective_policy_hash, - pre_submit_policy_id=facts.pre_submit_policy_id, - pre_submit_policy_bundle_hash=facts.pre_submit_policy_bundle_hash, - ), - effective_policy=json.loads(facts.effective_policy.value), - compiled_bundle=json.loads(facts.compiled_pre_submit_bundle.value), + policies = LockedExternalCheckerPolicies.model_validate_json( + facts.external_checker_policies.value ) - CompiledPostSubmitPolicy.model_validate_json( - facts.compiled_post_submit_policy.value, - ).validate_catalogue(self._post_submit_catalogue()) - except ValueError as exc: - raise TaskProjectNotReady("installed checkers cannot execute the locked policies") from exc + post = LockedExternalPostSubmitPolicy.model_validate_json( + facts.compiled_post_submit_policy.value + ) + if post != policies.post_submit: + raise ValueError("activated POST policy differs from selected images") + except (ValidationError, ValueError) as exc: + raise TaskProjectNotReady( + "installed checkers cannot execute the locked policies" + ) from exc async def _load_locked_task_context(self, task: WorkstreamTask) -> LockedTaskContext: """Resolve frozen custody without selecting current policy or deployment capabilities.""" missing = self._missing_locked_context_fields(task) if missing: - raise TaskLockedContextInvalid("task locked context is incomplete", {"missing_fields": missing}) + raise TaskLockedContextInvalid( + "task locked context is incomplete", {"missing_fields": missing} + ) try: facts = await self._project_contexts.lock_locked_policy_context( ProjectLockedPolicyContextRequest( - project_id=UUID(task.project_id), guide_version=task.locked_guide_version, + project_id=UUID(task.project_id), + guide_version=task.locked_guide_version, source_snapshot_id=UUID(task.locked_guide_source_snapshot_id), source_snapshot_hash=task.locked_guide_source_snapshot_hash, - effective_policy_id=UUID(task.locked_effective_project_submission_artifact_policy_id), + effective_policy_id=UUID( + task.locked_effective_project_submission_artifact_policy_id + ), effective_policy_hash=task.locked_effective_project_submission_artifact_policy_hash, pre_submit_policy_id=UUID(task.locked_pre_submit_checker_policy_id), pre_submit_policy_bundle_hash=task.locked_pre_submit_checker_bundle_hash, @@ -238,17 +247,34 @@ async def _load_locked_task_context(self, task: WorkstreamTask) -> LockedTaskCon expected = self._policy_stamps(facts) if any(getattr(task, name) != value for name, value in expected.items()): raise ValueError("task policy stamps differ from activation receipt") - parsed = CompiledPostSubmitPolicy.model_validate_json(facts.compiled_post_submit_policy.value) + parsed = self._parse_post_submit_policy_summary(facts) except (ProjectLockedPolicyContextUnavailable, ValueError, TypeError) as exc: raise TaskLockedContextInvalid("task locked policy custody is invalid") from exc return LockedTaskContext( facts=facts, - locked_post_submit_policy_body=PostSubmitPolicyBodySummary( - schema_version=parsed.schema_version, default_checkers=tuple(parsed.default_checkers), - required_checkers=tuple(parsed.required_checkers), warning_checkers=tuple(parsed.warning_checkers), - execution_checkers=tuple(parsed.execution_checkers), - blocking_severities=tuple(parsed.blocking_severities), - ), + locked_post_submit_policy_body=parsed, + ) + + @staticmethod + def _parse_post_submit_policy_summary( + facts: ProjectLockedPolicyContextFacts, + ) -> PostSubmitPolicyBodySummary: + """Decode the closed external POST policy bound to approval custody.""" + body = json.loads(facts.compiled_post_submit_policy.value) + selected = validate_stored_external_post_policy(body) + policies = LockedExternalCheckerPolicies.model_validate_json( + facts.external_checker_policies.value + ) + if selected != policies.post_submit: + raise ValueError("locked selected POST policy differs from approval") + checkers = tuple(item.registry.capability_id for item in selected.entries) + return PostSubmitPolicyBodySummary( + schema_version=selected.schema_version, + default_checkers=(), + required_checkers=checkers, + warning_checkers=(), + execution_checkers=checkers, + blocking_severities=("critical", "high"), ) def _missing_locked_context_fields(self, task: WorkstreamTask) -> list[str]: @@ -256,10 +282,14 @@ def _missing_locked_context_fields(self, task: WorkstreamTask) -> list[str]: return [field for field in LOCKED_CONTEXT_REQUIRED_FIELDS if not getattr(task, field)] def _contributor_submission_requirements_response( - self, task: WorkstreamTask, context: LockedTaskContext, + self, + task: WorkstreamTask, + context: LockedTaskContext, ) -> ContributorTaskSubmissionRequirements: """Compose the contributor projection shared by hidden and retained reads.""" - return ContributorTaskSubmissionRequirements(**self._submission_requirement_values(task, context)) + return ContributorTaskSubmissionRequirements( + **self._submission_requirement_values(task, context) + ) def _submission_requirement_values( self, @@ -375,10 +405,14 @@ def _submission_requirement_values( ), packaging=self._packaging_requirements(policy), maximum_archive_entries=self._optional_policy_non_negative_int( - policy, "maximum_archive_entries", minimum=1, + policy, + "maximum_archive_entries", + minimum=1, ), maximum_archive_size_bytes=self._optional_policy_non_negative_int( - policy, "maximum_archive_size_bytes", minimum=1, + policy, + "maximum_archive_size_bytes", + minimum=1, ), ) @@ -453,7 +487,8 @@ def _optional_policy_non_negative_int( self, policy: dict[str, Any], field: str, - *, minimum: int = 0, + *, + minimum: int = 0, ) -> int | None: """Return an optional non-negative integer from the locked policy.""" value = policy.get(field) @@ -552,7 +587,9 @@ def _locked_context_reference_values(task: WorkstreamTask) -> dict[str, object]: "locked_guide_version": task.locked_guide_version, "locked_guide_source_snapshot_id": UUID(task.locked_guide_source_snapshot_id), "locked_guide_source_snapshot_hash": task.locked_guide_source_snapshot_hash, - "locked_effective_project_submission_artifact_policy_id": UUID(task.locked_effective_project_submission_artifact_policy_id), + "locked_effective_project_submission_artifact_policy_id": UUID( + task.locked_effective_project_submission_artifact_policy_id + ), "locked_effective_project_submission_artifact_policy_hash": task.locked_effective_project_submission_artifact_policy_hash, "locked_pre_submit_checker_policy_id": UUID(task.locked_pre_submit_checker_policy_id), "locked_pre_submit_checker_bundle_hash": task.locked_pre_submit_checker_bundle_hash, @@ -569,7 +606,9 @@ def _locked_context_reference_values(task: WorkstreamTask) -> dict[str, object]: } def _management_locked_context_response( - self, task: WorkstreamTask, context: LockedTaskContext, + self, + task: WorkstreamTask, + context: LockedTaskContext, ) -> ManagementTaskLockedContext: """Compose the sole management projection, shared by hidden and retained reads.""" return ManagementTaskLockedContext( @@ -628,7 +667,9 @@ def _policy_stamps(facts: ProjectLockedPolicyContextFacts) -> dict[str, object]: "locked_post_submit_checker_policy_id": str(command.target.policy_id), "locked_post_submit_checker_policy_version": facts.guide_version, "locked_post_submit_checker_policy_hash": command.target.policy_hash, - "locked_post_submit_checker_policy_body": json.loads(facts.compiled_post_submit_policy.value), + "locked_post_submit_checker_policy_body": json.loads( + facts.compiled_post_submit_policy.value + ), "locked_review_policy_id": str(command.review.policy_id), "locked_review_policy_generation": command.review.generation, "locked_review_policy_hash": command.review.policy_hash, @@ -637,14 +678,18 @@ def _policy_stamps(facts: ProjectLockedPolicyContextFacts) -> dict[str, object]: "locked_revision_policy_hash": command.revision.policy_hash, "locked_guide_source_snapshot_id": str(facts.source_snapshot_id), "locked_guide_source_snapshot_hash": facts.source_snapshot_hash, - "locked_effective_project_submission_artifact_policy_id": str(facts.effective_policy_id), + "locked_effective_project_submission_artifact_policy_id": str( + facts.effective_policy_id + ), "locked_effective_project_submission_artifact_policy_hash": facts.effective_policy_hash, "locked_pre_submit_checker_policy_id": str(facts.pre_submit_policy_id), "locked_pre_submit_checker_bundle_hash": facts.pre_submit_policy_bundle_hash, } @staticmethod - def validate_submission_policy_context(task_context: TaskSubmissionContextFacts, project_context: ProjectLockedPolicyContextFacts) -> None: + def validate_submission_policy_context( + task_context: TaskSubmissionContextFacts, project_context: ProjectLockedPolicyContextFacts + ) -> None: """Reconcile every locked TASK policy stamp with its exact PROJECTS receipt.""" from app.modules.tasks.api.transition_audit import TaskPolicyLineage @@ -658,7 +703,9 @@ def validate_submission_policy_context(task_context: TaskSubmissionContextFacts, if getattr(task_context.locked_policy, key) != value: raise ValueError("task submission policy differs") - def _stamp_locked_context(self, task: WorkstreamTask, facts: ProjectLockedPolicyContextFacts) -> None: + def _stamp_locked_context( + self, task: WorkstreamTask, facts: ProjectLockedPolicyContextFacts + ) -> None: """Copy the exact activated guide and contribution-policy context.""" for name, value in self._policy_stamps(facts).items(): setattr(task, name, value) @@ -676,7 +723,6 @@ def _ensure_locked_context(self, task: WorkstreamTask) -> None: if missing: raise TaskTransitionBlocked(f"task missing locked context: {', '.join(missing)}") - @staticmethod def task_response_for_authority(task: WorkstreamTask, *, can_manage: bool) -> TaskResponse: """Use an explicit authorized projection, never inferred token roles.""" @@ -696,7 +742,6 @@ def task_response_for_authority(task: WorkstreamTask, *, can_manage: bool) -> Ta response.locked_pre_submit_checker_bundle_hash = None return response - def _ensure_transition_allowed(self, from_status: str, to_status: str) -> None: """Map lifecycle transition validation into service-layer errors. diff --git a/backend/app/modules/tasks/submission_composition.py b/backend/app/modules/tasks/submission_composition.py index f48ad1db2..6c9cd30e5 100644 --- a/backend/app/modules/tasks/submission_composition.py +++ b/backend/app/modules/tasks/submission_composition.py @@ -21,7 +21,7 @@ TaskSubmissionContextRequest, TaskSubmissionContextPort, ) -from app.modules.checkers.api import SubmissionPacketView +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView from app.modules.tasks.models import EvidenceItem, Submission from app.modules.tasks.submission_participants import ( SubmissionArtifactAdmissionPort, SubmissionArtifactAdmissionRequest, @@ -33,7 +33,9 @@ from app.modules.tasks.submission_replay import read_creation_replay from app.modules.tasks.lifecycle import ensure_allowed_transition from app.modules.checkers.api.execution import EvaluationCoordinationPort -from app.modules.checkers.api.post_submit import make_post_submit_request +from app.modules.checkers.api.selected_post_submit import ( + make_selected_post_submit_request, +) from app.modules.outbox.api import OutboxAppendPort from app.modules.tasks.repository import TaskRepository from app.modules.tasks.service import TaskService @@ -195,22 +197,40 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation type(consumed) is not SubmissionArtifactAdmissionResult or not isinstance(consumed.binding_id, UUID) or not isinstance(consumed.content_id, UUID) + or consumed.selected_evaluation_content is None ): - raise RuntimeError("artifact admission did not produce exact binding facts") + raise SubmissionCreationUnavailable("submission creation is unavailable") submission.submission_bundle_admission_id = str(request.admission_id) submission.artifact_binding_id = str(consumed.binding_id) submission.artifact_content_id = str(consumed.content_id) + submission.selected_post_submit_policy_sha256 = ( + consumed.selected_evaluation_content.policy.policy_sha256 + ) + submission.selected_post_submit_policy_body = ( + consumed.selected_evaluation_content.policy.model_dump(mode="json") + ) await self._session.flush() creation_decision_id = await self._authorization.consume(prepared_authorization, final) if not isinstance(creation_decision_id, UUID): raise SubmissionCreationUnavailable("submission creation is unavailable") - evaluation = make_post_submit_request( - **consumed.evaluation_content.model_dump(), - evaluation_request_id=new_record_id(), evaluation_generation=1, - task_id=request.task_id, assignment_id=request.assignment_id, - submission_id=submission_id, submission_version=version, - content_id=consumed.content_id, binding_id=consumed.binding_id, - content_sha256=consumed.archive_sha256, byte_count=consumed.byte_count, + request_fields = dict( + evaluation_request_id=new_record_id(), + evaluation_generation=1, + task_id=request.task_id, + assignment_id=request.assignment_id, + submission_id=submission_id, + submission_version=version, + content_id=consumed.content_id, + binding_id=consumed.binding_id, + content_sha256=consumed.archive_sha256, + byte_count=consumed.byte_count, + ) + selected = consumed.selected_evaluation_content + evaluation = make_selected_post_submit_request( + project_id=selected.project_id, + expected_context=selected.expected_context, + policy=selected.policy, + **request_fields, ) ensure_allowed_transition(task.status, "submitted") task.status = "submitted" @@ -262,6 +282,7 @@ async def _replay(self, request, receipt, context) -> SubmissionCreationResult: contributor_id=request.contributor_id, submission_id=UUID(receipt.submission_id), submission_version=receipt.submission_version, packet_sha256=SubmissionPacketView(request.summary, request.contributor_attestation).sha256, + task_context=context, )) if (consumed.binding_id, consumed.content_id, consumed.binding_decision_id) != ( UUID(receipt.artifact_binding_id), UUID(receipt.artifact_content_id), UUID(receipt.binding_decision_id), @@ -283,8 +304,10 @@ async def _replay(self, request, receipt, context) -> SubmissionCreationResult: or evaluation.content_id != consumed.content_id or evaluation.content_sha256 != consumed.archive_sha256 or evaluation.byte_count != consumed.byte_count - or evaluation.structural_input.summary != request.summary - or evaluation.structural_input.worker_attestation != request.contributor_attestation + or consumed.selected_evaluation_content is None + or evaluation.project_id != consumed.selected_evaluation_content.project_id + or evaluation.expected_context != consumed.selected_evaluation_content.expected_context + or evaluation.policy != consumed.selected_evaluation_content.policy ): raise SubmissionCreationUnavailable("submission creation is unavailable") await self._events.require_existing(receipt.evaluation_event_id, evaluation_request_event(receipt)) diff --git a/backend/app/modules/tasks/submission_participants.py b/backend/app/modules/tasks/submission_participants.py index 794908b32..9909faab9 100644 --- a/backend/app/modules/tasks/submission_participants.py +++ b/backend/app/modules/tasks/submission_participants.py @@ -1,11 +1,12 @@ """Private TASK composition ports; public TASK facts remain dependency-independent.""" -from dataclasses import dataclass +from dataclasses import dataclass, field as dataclass_field from typing import Protocol from uuid import UUID -from app.modules.checkers.api import SubmissionPacketView -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationContent +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView +from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy +from app.modules.checkers.api.selected_post_submit import SelectedPostSubmissionContent from app.modules.projects.api import ProjectLockedPolicyContextFacts from app.modules.tasks.api import TaskSubmissionContextFacts @@ -27,11 +28,21 @@ class SubmissionArtifactBindingFacts: binding_decision_id: UUID archive_sha256: str byte_count: int + selected_evaluation_content: SelectedPostSubmissionContent @dataclass(frozen=True, slots=True) class SubmissionArtifactAdmissionResult(SubmissionArtifactBindingFacts): - evaluation_content: PostSubmissionEvaluationContent + selected_post_submit_policy: LockedExternalPostSubmitPolicy = dataclass_field(kw_only=True) + selected_evaluation_content: SelectedPostSubmissionContent + + def __post_init__(self) -> None: + """Bind the sole POST contract to the policy admitted by ART.""" + if ( + type(self.selected_evaluation_content) is not SelectedPostSubmissionContent + or self.selected_evaluation_content.policy != self.selected_post_submit_policy + ): + raise ValueError("external POST admission policy differs") @dataclass(frozen=True, slots=True) @@ -44,6 +55,7 @@ class SubmissionArtifactReplayRequest: submission_id: UUID submission_version: int packet_sha256: str + task_context: TaskSubmissionContextFacts class SubmissionArtifactAdmissionPort(Protocol): diff --git a/backend/app/modules/tasks/submitted_bundle.py b/backend/app/modules/tasks/submitted_bundle.py index 826da986f..7f22fde22 100644 --- a/backend/app/modules/tasks/submitted_bundle.py +++ b/backend/app/modules/tasks/submitted_bundle.py @@ -44,6 +44,8 @@ async def read(self, request: SubmittedBundleRequest) -> SubmittedBundleFacts: Submission.locked_post_submit_checker_policy_id, Submission.locked_post_submit_checker_policy_version, Submission.locked_post_submit_checker_policy_hash, + Submission.selected_post_submit_policy_sha256, + Submission.selected_post_submit_policy_body, Submission.locked_review_policy_id, Submission.locked_review_policy_generation, Submission.locked_review_policy_hash, @@ -75,6 +77,12 @@ async def read(self, request: SubmittedBundleRequest) -> SubmittedBundleFacts: post_policy_id=UUID(row["locked_post_submit_checker_policy_id"]), post_policy_version=row["locked_post_submit_checker_policy_version"], post_policy_hash=row["locked_post_submit_checker_policy_hash"], + selected_post_policy_hash=row[ + "selected_post_submit_policy_sha256" + ], + selected_post_policy_body=row[ + "selected_post_submit_policy_body" + ], review_policy_id=UUID(row["locked_review_policy_id"]), review_generation=row["locked_review_policy_generation"], review_hash=row["locked_review_policy_hash"], diff --git a/backend/app/workers/outbox_topology.py b/backend/app/workers/outbox_topology.py index c76f863e8..3f18fcf22 100644 --- a/backend/app/workers/outbox_topology.py +++ b/backend/app/workers/outbox_topology.py @@ -6,8 +6,12 @@ from celery.concurrency.prefork import TaskPool from celery.exceptions import WorkerShutdown from celery.signals import worker_init +from app.modules.checkers.api.external_policy import SELECTED_POST_DELIVERY_LEASE_SECONDS -OUTBOX_HARD_LIMIT_SECONDS = 300 +OUTBOX_PROCESS_SHUTDOWN_MARGIN_SECONDS = 60 +OUTBOX_HARD_LIMIT_SECONDS = ( + SELECTED_POST_DELIVERY_LEASE_SECONDS + OUTBOX_PROCESS_SHUTDOWN_MARGIN_SECONDS +) OUTBOX_QUEUE = "workstream.outbox" OUTBOX_DELIVERY_TASK = "workstream.outbox.deliver_event" _validated_parent: int | None = None @@ -36,6 +40,10 @@ def require_outbox_worker(task): or task.request.called_directly ): raise RuntimeError("OUTBOX delivery requires a validated prefork worker") - hard_limit = (task.request.timelimit or (None, None))[0] or task.time_limit - if type(hard_limit) not in (int, float) or not 0 < hard_limit <= OUTBOX_HARD_LIMIT_SECONDS: + message_limit = (task.request.timelimit or (None, None))[0] + if ( + type(task.time_limit) is not int or task.time_limit != OUTBOX_HARD_LIMIT_SECONDS + or message_limit is not None + and (type(message_limit) is not int or message_limit != OUTBOX_HARD_LIMIT_SECONDS) + ): raise RuntimeError("OUTBOX delivery requires a bounded hard time limit") diff --git a/backend/app/workers/post_policy.py b/backend/app/workers/post_policy.py index e3d935ee3..3cedc548e 100644 --- a/backend/app/workers/post_policy.py +++ b/backend/app/workers/post_policy.py @@ -7,11 +7,11 @@ from app.adapters.auth import post_policy_service_authority from app.adapters.projects import ( - dispatch_post_policy_derivation_after_commit, pending_post_policy_approvals, + dispatch_post_policy_derivation_after_commit, + pending_post_policy_approvals, project_post_policy_delivery_port, ) from app.db.session import get_database_url -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.api.post_policy import PostPolicyDelivery from app.workers.async_runner import run_async_task from app.workers.celery_app import celery_app @@ -21,21 +21,27 @@ @celery_app.task( - name="workstream.project_setup.derive_post_policy", bind=True, - acks_late=True, reject_on_worker_lost=True, + name="workstream.project_setup.derive_post_policy", + bind=True, + acks_late=True, + reject_on_worker_lost=True, ) def derive_post_policy(self, approval_operation_id): """Fence the actual broker task identity and retry bounded infrastructure failures.""" try: delivery = PostPolicyDelivery( - approval_operation_id=approval_operation_id, task_id=self.request.id, + approval_operation_id=approval_operation_id, + task_id=self.request.id, ) except (TypeError, ValueError): return {"status": "delivery_rejected"} outcome = run_async_task(lambda: _derive(delivery)) if outcome["status"] == "derivation_unavailable": - raise self.retry(exc=RuntimeError("post-policy derivation unavailable"), - countdown=30 * (2 ** self.request.retries), max_retries=3) + raise self.retry( + exc=RuntimeError("post-policy derivation unavailable"), + countdown=30 * (2**self.request.retries), + max_retries=3, + ) return outcome @@ -45,7 +51,7 @@ async def _derive(delivery: PostPolicyDelivery) -> dict: try: return await project_post_policy_delivery_port( async_sessionmaker(engine, expire_on_commit=False), - authority=post_policy_service_authority, catalogue=current_post_submit_catalogue(), + authority=post_policy_service_authority, ).run(delivery) except Exception: logger.warning("post-policy derivation unavailable") @@ -68,8 +74,11 @@ def scan_post_policy_approvals(self, after=None): return result except Exception as exc: logger.warning("post-policy recovery scan unavailable") - raise self.retry(exc=RuntimeError("post-policy scan unavailable"), - countdown=30 * (2 ** self.request.retries), max_retries=3) from exc + raise self.retry( + exc=RuntimeError("post-policy scan unavailable"), + countdown=30 * (2**self.request.retries), + max_retries=3, + ) from exc async def _scan(after: UUID | None) -> dict: @@ -78,13 +87,18 @@ async def _scan(after: UUID | None) -> dict: try: candidates = await pending_post_policy_approvals( async_sessionmaker(engine, expire_on_commit=False), - after=after, limit=POST_POLICY_SCAN_PAGE_SIZE, + after=after, + limit=POST_POLICY_SCAN_PAGE_SIZE, ) published = 0 for approval_id in candidates: published += bool(await dispatch_post_policy_derivation_after_commit(approval_id)) - return {"selected": len(candidates), "published": published, - "next_after": str(candidates[-1]) - if len(candidates) == POST_POLICY_SCAN_PAGE_SIZE else None} + return { + "selected": len(candidates), + "published": published, + "next_after": str(candidates[-1]) + if len(candidates) == POST_POLICY_SCAN_PAGE_SIZE + else None, + } finally: await engine.dispose() diff --git a/backend/app/workers/project_setup.py b/backend/app/workers/project_setup.py index 2a25d59ab..7ce0dda5d 100644 --- a/backend/app/workers/project_setup.py +++ b/backend/app/workers/project_setup.py @@ -18,9 +18,8 @@ setup_finalization_authorization, ) from app.adapters.projects import project_guide_compilation_delivery_port -from app.adapters.checkers import project_guide_pre_submission_catalogue +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.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ProjectGuideCompilationDeliveryError, @@ -32,14 +31,15 @@ logger = get_task_logger(__name__) -def _coordinator(session_factory): +async def _coordinator(session_factory): """Compose owner ports and the sole registered runtime at the worker boundary.""" + async with session_factory() as session, session.begin(): + capabilities = await external_checker_capability_projection(session) return project_guide_compilation_delivery_port( session_factory, material_factory=guide_document_manifest_port, document_access_factory=partial(guide_document_access_runtime, session_factory), - pre_capabilities=project_guide_pre_submission_catalogue(), - post_capabilities=current_post_submit_catalogue(), + checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, configuration_factory=lambda: project_guide_runtime_configuration(get_settings()), @@ -84,7 +84,8 @@ async def _run_project_guide_compilation(delivery: ProjectGuideCompilationDelive """Own the worker connection lifecycle and return only bounded failures.""" engine = create_async_engine(get_database_url(), pool_pre_ping=True) try: - return await _coordinator(async_sessionmaker(engine, expire_on_commit=False)).run(delivery) + coordinator = await _coordinator(async_sessionmaker(engine, expire_on_commit=False)) + return await coordinator.run(delivery) except ProjectGuideCompilationDeliveryError: return {"status": "delivery_rejected", "error_code": "stale_compilation_delivery"} except Exception: diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 3eb34a6b5..18272d20f 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -167,6 +167,52 @@ "backend/app/modules/artifacts/preparation.py", } ) +PILOT_04_DEFAULT_CHECKER_TARGETS = frozenset( + { + "backend/app/modules/checkers/api/default_checker.py", + "backend/app/modules/checkers/default_checker.py", + } +) +PILOT_04_RETIRED_CATALOGUE_TARGETS = frozenset( + { + "backend/app/modules/artifacts/pre_submit_attempts.py", + "backend/app/modules/artifacts/pre_submit_evidence.py", + "backend/app/modules/checkers/api/historical_post_submit.py", + "backend/app/modules/checkers/api/policy_compilation.py", + "backend/app/modules/checkers/api/post_submit.py", + "backend/app/modules/checkers/api/pre_submit.py", + "backend/app/modules/checkers/api/pre_submit_catalogue.py", + "backend/app/modules/checkers/api/post_submit_catalogue.py", + "backend/app/modules/checkers/catalogue.py", + "backend/app/modules/checkers/compiler.py", + "backend/app/modules/checkers/effective_plan.py", + "backend/app/modules/checkers/post_submit_catalogue.py", + "backend/app/modules/checkers/post_submit_implementations.py", + "backend/app/modules/checkers/post_submit_contracts.py", + "backend/app/modules/checkers/pre_submit_execution.py", + "backend/app/modules/checkers/runner.py", + "backend/app/modules/projects/post_submit_policy.py", + } +) +PILOT_04_SELECTED_FLOW_TARGETS = frozenset( + { + "backend/app/modules/artifacts/selected_pre_submit_admission.py", + "backend/app/modules/artifacts/selected_pre_submit_execution.py", + "backend/app/modules/authorization/domain/pre_submit.py", + "backend/app/modules/authorization/pre_submit_authorization.py", + "backend/app/modules/checkers/api/canonical_values.py", + "backend/app/modules/checkers/api/evaluation_contract.py", + "backend/app/modules/checkers/api/external_policy.py", + "backend/app/modules/checkers/api/material.py", + "backend/app/modules/checkers/api/policy_custody.py", + "backend/app/modules/checkers/api/pre_submit_material.py", + "backend/app/modules/checkers/api/selected_post_submit.py", + "backend/app/modules/checkers/api/selected_pre_submit.py", + "backend/app/modules/checkers/selected_post_submit_execution.py", + "backend/app/modules/checkers/selected_pre_submit_coordination.py", + "backend/app/modules/checkers/selected_pre_submit_execution.py", + } +) LOCAL_PILOT_HELPER_TARGETS = frozenset( { "backend/scripts/ensure_local_minio_bucket.py", @@ -311,9 +357,7 @@ { "backend/app/modules/checkers/api/post_submit.py", "backend/app/modules/checkers/api/post_submit_catalogue.py", - "backend/app/modules/checkers/post_submit_catalogue.py", "backend/app/modules/checkers/post_submit_contracts.py", - "backend/app/modules/checkers/post_submit_implementations.py", } ) ARCH_02F_SUBMISSION_COMPOSITION_TARGETS = frozenset( @@ -832,7 +876,7 @@ def _validate_additive_partition_transition( ] if ( trusted_targets != sorted(trusted_targets) - or removed - (REV_04C_REMOVED_TARGETS | ARCH_04D2_REMOVED_TARGETS | ARCH_03D_REMOVED_TARGETS | TASK_CHECKER_CLEANUP_REMOVED_TARGETS | ARCH_03C2_REMOVED_TARGETS | OUTBOX_IDENTITY_REMOVED_TARGETS | V01_BASELINE_REMOVED_TARGETS | POL_03B_REMOVED_TARGETS | POL_04B_REMOVED_TARGETS | POL_05A_REMOVED_TARGETS | ARCH_03A_GUIDE_CONTEXT_REMOVED_TARGETS | ARCH_04B_SEAM_REMOVED_TARGETS) + or removed - (REV_04C_REMOVED_TARGETS | ARCH_04D2_REMOVED_TARGETS | ARCH_03D_REMOVED_TARGETS | TASK_CHECKER_CLEANUP_REMOVED_TARGETS | ARCH_03C2_REMOVED_TARGETS | OUTBOX_IDENTITY_REMOVED_TARGETS | V01_BASELINE_REMOVED_TARGETS | POL_03B_REMOVED_TARGETS | POL_04B_REMOVED_TARGETS | POL_05A_REMOVED_TARGETS | ARCH_03A_GUIDE_CONTEXT_REMOVED_TARGETS | ARCH_04B_SEAM_REMOVED_TARGETS | PILOT_04_RETIRED_CATALOGUE_TARGETS) or [current_by_target[item["target"]] for item in retained_trusted] != retained_trusted ): @@ -850,6 +894,8 @@ def _validate_additive_partition_transition( | PILOT_04_REGISTRY_TARGETS | PILOT_10_EXPORT_TARGETS | PILOT_04_RUNTIME_TARGETS + | PILOT_04_DEFAULT_CHECKER_TARGETS + | PILOT_04_SELECTED_FLOW_TARGETS | LOCAL_PILOT_HELPER_TARGETS | MODULE_BOUNDARY_FOUNDATION_TARGETS | MODULE_PUBLIC_API_FOUNDATION_TARGETS diff --git a/backend/scripts/identifier_generation_classifications.json b/backend/scripts/identifier_generation_classifications.json index 25c0a9962..2c0283bfb 100644 --- a/backend/scripts/identifier_generation_classifications.json +++ b/backend/scripts/identifier_generation_classifications.json @@ -28,8 +28,7 @@ "app/core/celery_observability.py|_task_prerun|Assign:88407f6b1931d9af|0": "uuid4:call:transport_token", "app/modules/actors/legacy_classification.py|_expected_actor_id|Return:dd984f00544f3859|0": "uuid5:call:external_identity_locator", "app/modules/artifacts/guide_document_access.py|ScopedGuideDocumentGrant.open|Assign:7e2866b7f96cc72a|0": "uuid4:call:transport_token", - "app/modules/artifacts/pre_submit_attempts.py|PreSubmitAttemptStore.reserve|Assign:dab8af3d0ea944db|0": "uuid4:call:lease_nonce", - "app/modules/artifacts/pre_submit_evidence.py|semantic_manifest_identity|Return:5c9bbd8b3ebba527|0": "uuid5:call:content_identity", + "app/modules/artifacts/submission_manifest.py|semantic_manifest_identity|Return:5c9bbd8b3ebba527|0": "uuid5:call:content_identity", "app/modules/authorization/admin_service.py|AdminRoleGrantService.bootstrap|Expr:efeb2a327e5efb1b|0": "uuid4:call:transport_token", "app/modules/authorization/admin_service.py|AdminRoleGrantService.bootstrap|Expr:687ae4c0e2b2fec8|0": "uuid4:call:transport_token", "app/modules/authorization/admin_service.py|AdminRoleGrantService.record_bootstrap_conflict|Expr:d5e0d0cc05ec64f2|0": "uuid4:call:transport_token", @@ -42,8 +41,8 @@ "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", "app/modules/outbox/delivery.py|OutboxDelivery._begin_invocation|AsyncWith:e2bb8a01c8a388c3|0": "uuid4:call:transport_token", - "app/modules/outbox/delivery.py|OutboxDelivery._finalize|AsyncWith:ceb28e7cbbeffa3e|0": "uuid4:call:transport_token", - "app/modules/outbox/delivery.py|OutboxDelivery._finalize|AsyncWith:669c031c700fb7fc|0": "uuid4:call:transport_token", + "app/modules/outbox/delivery.py|OutboxDelivery._finalize|AsyncWith:cbbdbac740d6b955|0": "uuid4:call:transport_token", + "app/modules/outbox/delivery.py|OutboxDelivery._finalize|AsyncWith:e0a55fcc39dc3452|0": "uuid4:call:transport_token", "app/modules/projects/api/post_policy.py|post_policy_derive_authorization_selector|Return:d6d5543c040dee68|0": "uuid5:call:deterministic_selector", "app/modules/projects/api/post_policy.py|post_policy_human_authorization_selector|Return:3de6b3c65d2d1c0f|0": "uuid5:call:deterministic_selector", "app/modules/projects/api/post_policy.py|post_policy_task_id|Return:b69ef92b74c1322c|0": "uuid5:call:deterministic_selector", diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index a3a34232c..8e20875cd 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -28,6 +28,7 @@ class TestLane: "tests/reviews/lifecycle/test_transitions.py", "tests/reviews/lifecycle/test_authority_binding.py", "tests/artifacts/test_review_packet_contract.py", + "tests/artifacts/test_selected_predecessor_rules.py", "tests/authorization/post_submit/test_atomicity.py", "tests/authorization/post_submit/test_concurrency.py", "tests/authorization/post_submit/test_live_authority.py", @@ -41,6 +42,7 @@ class TestLane: "tests/authorization/guide_activation/test_postgresql.py", "tests/authorization/guide_activation/test_concurrency.py", "tests/authorization/test_pre_submit_attempt_authority.py", + "tests/authorization/pre_submit/test_contracts.py", "tests/authorization/test_guide_metadata_contracts.py", "tests/authorization/post_policy/test_concurrency.py", "tests/authorization/post_policy/test_context.py", @@ -223,7 +225,6 @@ class TestLane: "tests/test_artifact_admission_digest.py", "tests/test_submission_bundle_admission.py", "tests/test_submission_bundle_preparation_recovery.py", - "tests/checkers/test_phase_service.py", "tests/test_submission_preparation_adapter.py", "tests/test_submission_preparation_authorization.py", "tests/test_submission_composition.py", @@ -248,7 +249,6 @@ class TestLane: "tests/test_project_policy_mutations.py", "tests/projects/review_policy/test_semantics.py", "tests/projects/review_policy/test_mutation.py", - "tests/projects/review_policy/test_activation.py", "tests/projects/review_policy/test_postgresql.py", "tests/projects/review_policy/test_schema.py", "tests/projects/review_policy/test_migration.py", @@ -325,7 +325,6 @@ class TestLane: "tests/projects/guide_compilation/finalization/test_replay.py", "tests/projects/guide_compilation/finalization/test_service.py", "tests/projects/guide_compilation/finalization/test_structure.py", - "tests/projects/post_policy/test_compiler.py", "tests/projects/post_policy/test_public_api.py", "tests/projects/post_policy/test_public_recovery.py", "tests/projects/post_policy/test_delivery_worker.py", @@ -334,7 +333,6 @@ class TestLane: "tests/projects/public_activation/test_failures.py", "tests/projects/public_activation/test_workflow.py", "tests/projects/post_policy/test_authority.py", - "tests/projects/post_policy/test_direct_sql.py", "tests/projects/post_policy/test_concurrency.py", "tests/projects/post_policy/test_replacement.py", "tests/projects/post_policy/test_inventory.py", @@ -344,7 +342,6 @@ class TestLane: "tests/projects/post_policy/test_postgresql.py", "tests/projects/guide_compilation/proposals/test_approved_tip.py", "tests/projects/guide_compilation/proposals/test_artifact_paths.py", - "tests/projects/guide_compilation/proposals/test_selected_checks.py", "tests/projects/guide_compilation/proposals/test_authority_contract.py", "tests/projects/guide_compilation/proposals/test_contracts.py", "tests/projects/guide_compilation/proposals/test_audit_schema.py", @@ -397,7 +394,6 @@ class TestLane: "tests/projects/test_locked_policy_contract.py", "tests/projects/test_locked_policy_custody.py", "tests/projects/test_locked_policy_concurrency.py", - "tests/projects/test_activation_readiness.py", "tests/projects/test_policy_read_composition.py", "tests/projects/test_active_guide_read_composition.py", "tests/projects/test_diagnostic_read_composition.py", @@ -423,13 +419,21 @@ class TestLane: # Checker delivery shares project dispatch ownership and the existing # four-way partition; exclusive placement exceeded the hosted execution cap. CHECKER_DELIVERY_MODULES = ( + "tests/checkers/default/test_contract.py", + "tests/checkers/default/test_execution.py", "tests/checkers/external/test_contracts.py", "tests/checkers/external/test_runtime.py", "tests/checkers/external/test_migration.py", "tests/checkers/external/test_postgresql.py", "tests/checkers/external/test_registry.py", - "tests/checkers/execution/test_results.py", + "tests/checkers/external/test_policy.py", + "tests/checkers/external/test_selected_post_submit.py", + "tests/checkers/external/test_selected_pre_submit.py", + "tests/checkers/external/test_selected_pre_submit_coordination.py", + "tests/checkers/external/test_selected_pre_submit_service.py", + "tests/checkers/external/test_selected_preparation_command.py", "tests/checkers/execution/test_execution.py", + "tests/checkers/execution/test_completion_evidence.py", "tests/checkers/execution/test_coordination.py", "tests/checkers/execution/test_concurrency.py", "tests/checkers/execution/test_storage.py", @@ -442,7 +446,6 @@ class TestLane: "tests/checkers/execution/test_checker_retry_migration.py", "tests/test_post_submit_materialization.py", "tests/test_post_submit_selection.py", - "tests/test_submission_evaluation_capacity.py", "tests/test_checker_output_custody.py", "tests/test_checker_output_storage.py", ) @@ -452,9 +455,7 @@ class TestLane: "tests/tasks/test_task_import_contract.py", "tests/test_task_import_sources.py", "tests/test_submission_predecessor_custody.py", - "tests/checkers/execution/test_completion_evidence.py", "tests/checkers/execution/test_completion_contract.py", - "tests/tasks/post_submit_routing/test_failure_completion.py", "tests/tasks/post_submit_routing/test_remediation.py", "tests/tasks/post_submit_routing/test_remediation_storage.py", "tests/tasks/post_submit_routing/test_remediation_currentness.py", @@ -512,29 +513,8 @@ class TestLane: "tests/tasks/test_assignment_invalidation_races.py", "tests/tasks/test_contribution_claim_races.py", "tests/tasks/test_submission_lineage.py", - "tests/checkers/post_submit/test_catalogue.py", - "tests/checkers/post_submit/test_compiled_policy.py", - "tests/checkers/post_submit/test_configuration.py", - "tests/checkers/post_submit/test_conformance.py", - "tests/checkers/post_submit/test_input_bounds.py", "tests/checkers/post_submit/test_public_boundary.py", - "tests/checkers/post_submit/test_request.py", "tests/checkers/post_submit/test_requirement_dispositions.py", - "tests/checkers/post_submit/test_result_contract.py", - "tests/test_checker_catalogue.py", - "tests/test_checkers.py", - "tests/checkers/test_effective_intake_rules.py", - "tests/test_blocked_pre_submit_feedback_postgresql.py", - "tests/test_default_pre_submit_execution.py", - "tests/test_approved_guide_intake.py", - "tests/test_pre_submit_attempt_recovery.py", - "tests/test_pre_submit_attempt_contracts.py", - "tests/test_pre_submit_attempt_authority_integration.py", - "tests/test_pre_submit_attempt_lock_order.py", - "tests/test_pre_submit_role_issue_lock_order.py", - "tests/test_pre_submit_related_lock_order.py", - "tests/test_pre_submit_attempt_schema.py", - "tests/test_effective_pre_submit_execution.py", "tests/test_project_guide_compilation_contracts.py", "tests/test_review_queue_persistence.py", "tests/test_review_lease_persistence.py", diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index b260e7b90..bdb059ac5 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -4,26 +4,9 @@ import json from pathlib import Path -from app.core.identifiers import new_record_id import pytest -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api import ( - EffectivePreSubmissionExecutionPlan, - EffectivePreSubmissionPlanLineage, - EffectivePreSubmissionPlanningPort, -) -from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue -from app.modules.checkers.compiler import compile_effective_project_submission_artifact_policy -from app.modules.checkers.pre_submit_execution import ( - PreSubmissionEntryResult, - PreSubmissionExecutionCustody, - PreSubmissionExecutionResult, - PreSubmissionResultDefinition, - PreSubmissionResultPolicyTrace, - PreSubmissionResultStatus, -) from scripts import module_boundaries as boundary from scripts.authorization_boundary import AuthorizationBoundaryError @@ -287,6 +270,7 @@ def test_projects_public_api_has_no_private_or_mutable_dependency() -> None: assert all( not target.startswith("app.modules.") or target.startswith("app.modules.projects.api") + or target == "app.modules.checkers.api.external_policy" for target in imports ) @@ -310,100 +294,6 @@ def test_checkers_public_api_has_no_private_or_mutable_dependency() -> None: ) -def test_checkers_public_planning_port_uses_the_canonical_compiler() -> None: - """The public port delegates to the sole deterministic compiler.""" - defaults = { - "required_packet_fields": ["summary", "worker_attestation"], - "forbidden_artifacts": [{"pattern": ".env"}], - "attestation_terms": ["rights_confirmed"], - } - policy = { - "workstream_default_policy": defaults, - "project_policy": {}, - **defaults, - "required_artifacts": [{"key": "task.toml", "path": "task.toml", "required": True}], - "required_evidence": [{"key": "results", "required": True}], - "manifest_required": True, - "artifact_hash_required": True, - "artifact_hash_algorithm": "sha256", - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": 1_000_000, - "maximum_package_size_bytes": 5_000_000, - "packaging": {"package_required": True, "allowed_package_formats": ["zip"]}, - } - policy_hash = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, policy_hash) - lineage = EffectivePreSubmissionPlanLineage( - project_id=new_record_id(), guide_id=new_record_id(), guide_version="1", - source_snapshot_id=new_record_id(), source_snapshot_hash="sha256:" + "1" * 64, - effective_policy_id=new_record_id(), effective_policy_hash=policy_hash, - pre_submit_policy_id=new_record_id(), - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, - ) - port: EffectivePreSubmissionPlanningPort = build_pre_submission_checker_catalogue() - plan = port.compile_effective_plan( - lineage=lineage, effective_policy=policy, compiled_bundle=compiled.compiled_bundle - ) - repeated = port.compile_effective_plan( - lineage=lineage, effective_policy=policy, compiled_bundle=compiled.compiled_bundle - ) - assert type(plan) is EffectivePreSubmissionExecutionPlan - assert plan.lineage is lineage - assert plan.plan_sha256 == canonical_json_hash(plan.as_dict()) - assert repeated.as_dict() == plan.as_dict() - assert repeated.plan_sha256 == plan.plan_sha256 - - -def test_checkers_public_execution_facts_exclude_artifact_custody() -> None: - """The public result projection cannot leak ART-owned custody fields.""" - execution = PreSubmissionExecutionResult( - plan_sha256="sha256:" + "1" * 64, - custody=PreSubmissionExecutionCustody( - prepared_generation_id=new_record_id(), archive_sha256="sha256:" + "2" * 64, - archive_byte_count=7, semantic_manifest_sha256="sha256:" + "3" * 64, - storage_scheme="s3", - ), - eligible=True, - entries=(PreSubmissionEntryResult( - schema_version="pre_submission_checker_result.v1", - definition=PreSubmissionResultDefinition( - dispatch_authority="workstream.pre_submission_checker_catalogue", - definition_id="submission_archive.outer_zip_valid", - definition_version="v0.1", public_name="Outer ZIP validation", - source="platform_default", - ), - policy_trace=PreSubmissionResultPolicyTrace( - effective_plan_sha256="sha256:" + "1" * 64, - rule_instance_id=None, locked_policy_sha256="sha256:" + "4" * 64, - ), - phase="custody", order=10, classification="mandatory_security", - severity="blocking", status=PreSubmissionResultStatus.PASSED, - failure_code=None, message_code="passed", - metadata=( - ("entry_count", 2), - ("finding_count", -1), - ("matched_category_count", True), - ("custody_id", 3), - ("archive_sha256", 5), - ("scratch_path", 4), - ("provider_key", 9), - ("evidence_id", 6), - ), - ),), - ) - facts = execution.bounded_facts() - assert ( - facts.plan_sha256, - facts.eligible, - facts.entries[0].checker_execution_status, - ) == ( - execution.plan_sha256, True, "passed" - ) - assert facts.entries[0].metadata == (("entry_count", 2),) - assert not hasattr(facts, "custody") - assert not hasattr(facts, "storage_scheme") - - def test_cyclic_public_dependencies_fail_closed() -> None: """Typed public facades cannot form an architectural dependency cycle.""" graph = {name: set() for name in boundary.load_registry(REGISTRY).names} @@ -414,7 +304,7 @@ def test_cyclic_public_dependencies_fail_closed() -> None: def test_art_implements_checker_consumer_ports_without_a_public_cycle() -> None: - """The delivered seam is ART implementation to CHECKERS-owned public ports.""" + """Selected checker execution has no private CHECKERS-to-ART dependency.""" registry = boundary.load_registry(REGISTRY) private, graph, _ = boundary.scan(ROOT, registry) assert "checkers" in graph["artifacts"] @@ -424,20 +314,7 @@ def test_art_implements_checker_consumer_ports_without_a_public_cycle() -> None: for edge in private if edge.source_file.startswith("backend/app/modules/checkers/") and edge.target_module == "artifacts" - } == { - ( - "backend/app/modules/checkers/pre_submit_execution.py", - "app.modules.artifacts.sources", - ), - ( - "backend/app/modules/checkers/pre_submit_execution.py", - "app.modules.artifacts.submission_archive", - ), - ( - "backend/app/modules/checkers/pre_submit_execution.py", - "app.modules.artifacts.submission_manifest", - ), - } + } == set() boundary._validate_acyclic(graph) # noqa: SLF001 - architecture proof diff --git a/backend/tests/archive_fixtures.py b/backend/tests/archive_fixtures.py new file mode 100644 index 000000000..56bc49bdb --- /dev/null +++ b/backend/tests/archive_fixtures.py @@ -0,0 +1,30 @@ +"""Deterministic ZIP bytes shared by current ART and TASK integration tests.""" + +from io import BytesIO +from zipfile import ZipFile, ZipInfo + + +async def byte_source(value: bytes): + """Yield one immutable request body through the production async-byte port.""" + yield value + + +def submission_archive( + path: str = "task.toml", + *, + primary_bytes: bytes = b"[task]\nname='proof'\n", + extra_path: str | None = None, + evidence_path: str = "evidence/results", +) -> bytes: + """Return a timestamp-stable ZIP with one required artifact and evidence file.""" + output = BytesIO() + with ZipFile(output, "w") as archive: + def write(name: str, value: bytes) -> None: + archive.writestr(ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)), value) + + write(path, primary_bytes) + if extra_path is not None: + write(extra_path, b"blocked\n") + if path != evidence_path: + write(evidence_path, b"verified\n") + return output.getvalue() diff --git a/backend/tests/artifacts/test_selected_predecessor_rules.py b/backend/tests/artifacts/test_selected_predecessor_rules.py new file mode 100644 index 000000000..f54ccf404 --- /dev/null +++ b/backend/tests/artifacts/test_selected_predecessor_rules.py @@ -0,0 +1,66 @@ +"""Predecessor rules require selected custody, never a substitute context hash.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.core.identifiers import new_record_id +from app.modules.artifacts.api import SubmissionBundlePreparationRejected +from app.modules.artifacts.submission_admission import PreparedSubmissionBundlePreparationCommand + + +def _command(admission): + return PreparedSubmissionBundlePreparationCommand( + session=SimpleNamespace(scalar=AsyncMock(return_value=admission)), + authority=SimpleNamespace(), + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), + runtime_factory=Mock(), + ) + + +def _task(): + return SimpleNamespace(predecessor=SimpleNamespace(submission_id=new_record_id())) + + +@pytest.mark.asyncio +async def test_predecessor_uses_only_its_selected_rules_commitment(): + admission = SimpleNamespace( + selected_pre_submit_run_id=new_record_id(), + pre_submit_evidence_set_id=None, + selected_rules_sha256="sha256:" + "1" * 64, + locked_policy_context_hash="sha256:" + "2" * 64, + ) + command = _command(admission) + assert await command._load_predecessor_rules(_task()) == admission.selected_rules_sha256 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing", ("admission", "run", "rules", "contradictory_evidence")) +async def test_missing_selected_predecessor_custody_rejects(missing): + admission = SimpleNamespace( + selected_pre_submit_run_id=new_record_id(), + pre_submit_evidence_set_id=None, + selected_rules_sha256="sha256:" + "1" * 64, + locked_policy_context_hash="sha256:" + "2" * 64, + ) + if missing == "admission": + admission = None + elif missing == "run": + admission.selected_pre_submit_run_id = None + elif missing == "rules": + admission.selected_rules_sha256 = None + else: + admission.pre_submit_evidence_set_id = new_record_id() + command = _command(admission) + with pytest.raises(SubmissionBundlePreparationRejected) as rejected: + await command._load_predecessor_rules(_task()) + assert str(rejected.value) == "submission_canonical_predecessor_unavailable" + + +@pytest.mark.asyncio +async def test_initial_submission_needs_no_predecessor_rules(): + command = _command(None) + assert await command._load_predecessor_rules(SimpleNamespace(predecessor=None)) is None + command._session.scalar.assert_not_called() 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 bd16dc0e9..dc89a6405 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", 78), ("admin-role-definitions", 5)]) +@pytest.mark.parametrize("surface,total", [("permissions", 80), ("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 aa13ff32d..7fcfef61b 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"]) == (78 if surface == "permissions" else 5) + assert body["total"] == len(body["items"]) == (80 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 9a3687bc2..3ae8dee57 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -20,6 +20,10 @@ "artifact.post_submit.checker_input.materialize", "artifact.review_packet.materialize", }, + ServiceIdentity.CHECKER_PRE_SUBMIT: { + "checker.pre_submit.execute", + "checker.pre_submit.finalize", + }, ServiceIdentity.CHECKER_POST_SUBMIT: {"checker.post_submit.execute", "checker.post_submit.finalize"}, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: {"artifact.checker_output.write"}, ServiceIdentity.PROJECT_SETUP: { @@ -253,7 +257,7 @@ new_permissions = frozenset( """ - task.post_submit.route checker.post_submit.execute checker.post_submit.finalize task.assignment.authority_reconcile outbox.dispatch project.setup_diagnostic.read project.effective_policy.read + task.post_submit.route checker.pre_submit.execute checker.pre_submit.finalize checker.post_submit.execute checker.post_submit.finalize task.assignment.authority_reconcile outbox.dispatch project.setup_diagnostic.read project.effective_policy.read operations.task.start_override operations.submission_gate.repair operations.checker.retry artifact.binding.read artifact.replica.read artifact.receipt.read artifact.verification_job.read @@ -269,6 +273,8 @@ "contribution.read_self": ("contribution.read_self", "PILOT-10"), "contribution.read_project": ("contribution.read_project", "PILOT-10"), "task.post_submit.route": ("task.post_submit.route", "WS-ARCH-001-04E2"), + "checker.pre_submit.execute": ("checker.pre_submit.execute", "PILOT-04"), + "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.registry.register": ("operations.reconcile.run", "PILOT-04"), @@ -445,6 +451,8 @@ AUDIT_ALLOWED_ACTION_VALUES = { + "checker.pre_submit.execute", + "checker.pre_submit.finalize", "operations.checker.retry", "contribution.read_self", "contribution.read_project", diff --git a/backend/tests/authorization/contribution_policies/test_cross_owner_lock_order.py b/backend/tests/authorization/contribution_policies/test_cross_owner_lock_order.py index 72c0de3c8..5505a55cc 100644 --- a/backend/tests/authorization/contribution_policies/test_cross_owner_lock_order.py +++ b/backend/tests/authorization/contribution_policies/test_cross_owner_lock_order.py @@ -3,29 +3,22 @@ from __future__ import annotations import asyncio -from dataclasses import replace -from pathlib import Path from uuid import UUID, uuid4 import pytest -from sqlalchemy import select, text +from sqlalchemy import text from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.adapters.auth import ( - compensation_adapter_binding_authorization, contribution_policy_authorization, + compensation_adapter_binding_authorization, ) -from app.adapters.contributions import contribution_policy_service, contribution_policy_validation_port +from app.adapters.contributions import contribution_policy_validation_port from app.db import session as db_session -from app.modules.artifacts.models import PreSubmitEvidenceSet from app.modules.authorization.repository import AdminAuthorizationRepository -from app.modules.authorization.runtime import ( - ActorKind, ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, -) from app.modules.compensation.api import AdapterBindingSuspendRequest from app.modules.compensation.service import AdapterBindingService from app.modules.contributions.api import ( - ContributionPolicyCreateDraftRequest, ContributionPolicyValidationPurpose, + ContributionPolicyValidationPurpose, ContributionPolicyValidationRequest, ) from app.modules.projects.contribution_policy import ProjectContributionPolicyEligibility @@ -34,13 +27,6 @@ from tests.authorization.contribution_policies.test_selected_validation_postgresql import ( published_world, ) -from tests.authorization.test_pre_submit_attempt_authority import _seed_materializer -from tests.test_pre_submit_attempt_authority_integration import _reserve_with_real_materializer -from tests.test_pre_submit_attempt_recovery import _harness -from tests.test_pre_submit_related_lock_order import ( - _PauseBeforeProject, _assert_row_locked, _ready_workflow, -) -from project_create_fixtures import grant_fixture_admin_role def _role_body(target_id: UUID) -> dict: @@ -253,112 +239,3 @@ async def suspend_binding(): finally: release.set() await asyncio.gather(*tasks, return_exceptions=True) - - -class _ObservedBeforeProject(_PauseBeforeProject): - def __init__(self, repository, session, entered, release): - super().__init__(repository, entered, release) - self.session = session - self.backend_pid: int | None = None - - async def lock_locked_policy_context(self, request): - self.backend_pid = int(await self.session.scalar(text("select pg_backend_pid()"))) - return await super().lock_locked_policy_context(request) - - -@pytest.mark.asyncio -async def test_dual_finance_submitter_profile_precedes_policy_project_lock( - tmp_path: Path, isolated_database_env: str, monkeypatch, -) -> None: - import tests.test_default_pre_submit_execution as default_tests - - original_limits = default_tests._limits - monkeypatch.setattr(default_tests, "_limits", lambda: replace( - original_limits(), total_deadline_seconds=90, reservation_ttl_seconds=120, - aggregate_reserved_bytes=4 * default_tests.HARD_MAXIMUM_ARTIFACT_BYTES, - )) - harness = await _harness(tmp_path, isolated_database_env) - con_engine = None - art_task = con_task = None - release_art = asyncio.Event() - try: - await _seed_materializer(harness.factory) - project_id = harness.request.effective_plan.lineage.project_id - async with harness.factory.begin() as setup: - await grant_fixture_admin_role( - setup, harness.actor_id, role="finance_authority", - scope="project", project_id=project_id, - ) - con_name = f"cp07a-dual-finance-{uuid4().hex[:10]}" - con_engine = create_async_engine( - isolated_database_env, - connect_args={"server_settings": {"application_name": con_name}}, - ) - con_factory = async_sessionmaker(con_engine, expire_on_commit=False) - draft = ContributionPolicyCreateDraftRequest( - actor_profile_id=harness.actor_id, project_id=project_id, - operation_id=uuid4(), name="Dual role lock order", - ) - async with harness.factory() as art_session, con_factory() as con_session: - calls: list[int] = [] - workflow, materializer = await _ready_workflow(harness, art_session, calls) - reservation = await _reserve_with_real_materializer( - workflow, harness.request, harness.preparation_request, - ) - art_before_project = asyncio.Event() - paused = _ObservedBeforeProject( - workflow._project_contexts, art_session, - art_before_project, release_art, - ) - workflow._project_contexts = paused - context = HumanAuthorizationContext( - actor_profile_id=harness.actor_id, actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, identity_link_id=harness.identity_link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=uuid4(), correlation_id=uuid4(), - ) - - async def create_draft(): - async with con_session.begin(): - authority = contribution_policy_authorization(con_session, context) - return await contribution_policy_service( - con_session, read_authorization=authority, - mutation_authorization=authority, - ).create_draft(draft) - - try: - art_task = asyncio.create_task(workflow.execute_reserved( - harness.request, reservation, - preparation_request=harness.preparation_request, - )) - await asyncio.wait_for(art_before_project.wait(), timeout=30) - assert paused.backend_pid is not None - await _assert_row_locked(harness.engine, "actor_profiles", harness.actor_id) - await _assert_row_locked( - harness.engine, "actor_identity_links", harness.identity_link_id, - ) - con_task = asyncio.create_task(create_draft()) - await _wait_edge( - isolated_database_env, con_name, None, paused.backend_pid, - ) - release_art.set() - art_result, policy_result = await asyncio.wait_for( - asyncio.gather(art_task, con_task), timeout=60, - ) - assert art_result.pass_capability is not None - assert policy_result.operation_id == draft.operation_id - assert calls == [1] - finally: - release_art.set() - await asyncio.gather( - *(task for task in (art_task, con_task) if task is not None), - return_exceptions=True, - ) - materializer.close() - async with harness.factory() as check: - assert await check.scalar(select(PreSubmitEvidenceSet.id).limit(1)) is not None - finally: - release_art.set() - if con_engine is not None: - await con_engine.dispose() - await harness.close() diff --git a/backend/tests/authorization/guide_activation/pg_support.py b/backend/tests/authorization/guide_activation/pg_support.py index e8a5095a1..5b47ffa76 100644 --- a/backend/tests/authorization/guide_activation/pg_support.py +++ b/backend/tests/authorization/guide_activation/pg_support.py @@ -6,11 +6,14 @@ from sqlalchemy import text from app.adapters.auth import guide_activation_authorization -from app.adapters.checkers import project_guide_approval_compiler from app.adapters.contributions import contribution_policy_validation_port from app.adapters.projects import project_guide_activation_port from app.adapters.projects.contribution_validation import GuideContributionPolicyValidation -from app.modules.authorization.runtime import HumanAuthorizationContext, ActorStatus, IdentityLinkStatus +from app.modules.authorization.runtime import ( + HumanAuthorizationContext, + ActorStatus, + IdentityLinkStatus, +) @asynccontextmanager @@ -18,15 +21,19 @@ async def service(factory, actor): async with factory() as session, session.begin(): request = uuid4() context = HumanAuthorizationContext( - actor_profile_id=actor.actor_profile_id, identity_link_id=actor.identity_link_id, - actor_kind=actor.actor_kind, actor_status=ActorStatus.ACTIVE, - identity_link_status=IdentityLinkStatus.ACTIVE, request_id=request, correlation_id=uuid4(), + actor_profile_id=actor.actor_profile_id, + identity_link_id=actor.identity_link_id, + actor_kind=actor.actor_kind, + actor_status=ActorStatus.ACTIVE, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=request, + correlation_id=uuid4(), ) - planner, pre, post = project_guide_approval_compiler() owner = project_guide_activation_port( session, - contribution=GuideContributionPolicyValidation(contribution_policy_validation_port(session)), - planner=planner, pre_catalogue=pre, post_catalogue=post, + contribution=GuideContributionPolicyValidation( + contribution_policy_validation_port(session) + ), authorization=guide_activation_authorization(session, context), ) yield session, owner, request, context @@ -40,18 +47,30 @@ async def activate(factory, actor, command): async def activation_state(factory): async with factory() as session: return { - "guides": (await session.execute(text( - "SELECT id,status,activation_operation_id,contribution_policy_version_id,mutation_generation " - "FROM project_guides ORDER BY id" - ))).all(), - "operations": (await session.execute(text( - "SELECT operation_id,resource_context_digest,activation_authority_json FROM guide_mutation_idempotency_records " - "WHERE action_id='project.guide.activate' ORDER BY operation_id" - ))).all(), - "events": (await session.execute(text( - "SELECT id,after_facts,matched_grant_id,correlation_id FROM audit_events " - "WHERE action_id='project.guide.activate' ORDER BY id" - ))).all(), + "guides": ( + await session.execute( + text( + "SELECT id,status,activation_operation_id,contribution_policy_version_id,mutation_generation " + "FROM project_guides ORDER BY id" + ) + ) + ).all(), + "operations": ( + await session.execute( + text( + "SELECT operation_id,resource_context_digest,activation_authority_json FROM guide_mutation_idempotency_records " + "WHERE action_id='project.guide.activate' ORDER BY operation_id" + ) + ) + ).all(), + "events": ( + await session.execute( + text( + "SELECT id,after_facts,matched_grant_id,correlation_id FROM audit_events " + "WHERE action_id='project.guide.activate' ORDER BY id" + ) + ) + ).all(), } @@ -61,16 +80,23 @@ async def revoke_manager_grant(session, context, grant): from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.runtime import AdminRoleGrantResourceContext - from app.modules.authorization.schemas import AdminRoleGrantRevokeRequest, AuthorityOperation, derive_reason_digest + from app.modules.authorization.schemas import ( + AdminRoleGrantRevokeRequest, + AuthorityOperation, + derive_reason_digest, + ) reason = "Manager authority revoked" request = AdminRoleGrantRevokeRequest( - operation=AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE, grant_id=grant, + operation=AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE, + grant_id=grant, reason_digest=derive_reason_digest(reason), ) owner = AdminRoleGrantService(session) reservation = await owner.reserve( - idempotency_key=uuid4(), actor_profile_id=context.actor_profile_id, request=request, + idempotency_key=uuid4(), + actor_profile_id=context.actor_profile_id, + request=request, ) assert reservation.outcome == "claimed" decision = await AuthorizationService(session, context).require( @@ -78,6 +104,9 @@ async def revoke_manager_grant(session, context, grant): AdminRoleGrantResourceContext(resource_type="admin_role_grant", resource_id=grant), ) return await owner.complete_revoke( - claim=reservation.claim, request=request, decision=decision, - actor_profile_id=context.actor_profile_id, reason=reason, + claim=reservation.claim, + request=request, + decision=decision, + actor_profile_id=context.actor_profile_id, + reason=reason, ) diff --git a/backend/tests/authorization/guide_proposals/test_postgresql.py b/backend/tests/authorization/guide_proposals/test_postgresql.py index 92cab4e4c..75ec64d4a 100644 --- a/backend/tests/authorization/guide_proposals/test_postgresql.py +++ b/backend/tests/authorization/guide_proposals/test_postgresql.py @@ -15,11 +15,6 @@ HumanAuthorizationContext, IdentityLinkStatus, ) -from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, - project_guide_pre_submission_capabilities, -) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.api.guide_proposals import ( GuideProposalApproval, GuideProposalCorrection, @@ -52,9 +47,15 @@ async def service(factory, actor): correlation_id=uuid4(), ) try: + from app.adapters.checkers import external_checker_registry_reader + yield ( session, - GuideProposalService(session, GuideProposalAuthorizationAdapter(session, context)), + GuideProposalService( + session, + GuideProposalAuthorizationAdapter(session, context), + external_checker_registry_reader(session), + ), request, ) except GuideProposalError as exc: @@ -78,15 +79,14 @@ async def mutate(factory, actor, payload): async with service(factory, actor) as (session, owner, request): if isinstance(payload, GuideProposalCorrection): return await owner.request_correction(payload, actor=actor, request_id=request) - planner = build_pre_submission_checker_catalogue() + from app.adapters.checkers import external_checker_capability_projection + return await owner.approve( payload, actor=actor, request_id=request, material=guide_document_manifest_port(session), - pre_capabilities=project_guide_pre_submission_capabilities(planner), - post_capabilities=current_post_submit_catalogue(), - planner=planner, + capabilities=await external_checker_capability_projection(session), ) diff --git a/backend/tests/authorization/post_policy/pg_support.py b/backend/tests/authorization/post_policy/pg_support.py index 4f33a79df..07eec9e4b 100644 --- a/backend/tests/authorization/post_policy/pg_support.py +++ b/backend/tests/authorization/post_policy/pg_support.py @@ -17,7 +17,6 @@ ) from app.modules.authorization.api import ActorKind from app.modules.actors.api import ServiceIdentity -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.post_policy.service import PostPolicyService from app.modules.projects.api.post_policy import ( PostPolicyApproval, @@ -66,7 +65,6 @@ async def service(factory, actor): PostPolicyService( session, post_policy_authorization(session, context), - current_post_submit_catalogue(), ), request, context, diff --git a/backend/tests/authorization/post_submit/test_live_authority.py b/backend/tests/authorization/post_submit/test_live_authority.py index 6f2cfd207..3bc4e1448 100644 --- a/backend/tests/authorization/post_submit/test_live_authority.py +++ b/backend/tests/authorization/post_submit/test_live_authority.py @@ -103,7 +103,7 @@ async def test_revoked_after_consumer_cannot_publish(tmp_path, isolated_database async with material_fixture(tmp_path, isolated_database_env) as h: reserved = await reserve(h) entered, release = asyncio.Event(), asyncio.Event() - original = execution._StructuralConsumer.evaluate + original = execution._SelectedExternalConsumer.evaluate calls = [] async def paused(consumer, request, material): @@ -113,7 +113,7 @@ async def paused(consumer, request, material): await release.wait() return value - monkeypatch.setattr(execution._StructuralConsumer, "evaluate", paused) + monkeypatch.setattr(execution._SelectedExternalConsumer, "evaluate", paused) operation = asyncio.create_task(live_executor(h).evaluate_post_submission(h.request)) try: await asyncio.wait_for(entered.wait(), 15) @@ -146,8 +146,7 @@ async def test_execution_identity_substitution_denies_before_material_access(tmp reservation = reservation.model_copy(update={field: getattr(foreign.lease.reservation, field)}) elif field == "project_id": request = change_request(request, project_id=other.request.project_id, - policy=other.request.policy, expected_context=other.request.expected_context, - structural_input=other.request.structural_input) + policy=other.request.policy, expected_context=other.request.expected_context) elif field == "evaluation_request_id": request = change_request(request, evaluation_request_id=other.request.evaluation_request_id) else: diff --git a/backend/tests/authorization/post_submit/test_migration.py b/backend/tests/authorization/post_submit/test_migration.py index 40c183873..7bf264db2 100644 --- a/backend/tests/authorization/post_submit/test_migration.py +++ b/backend/tests/authorization/post_submit/test_migration.py @@ -8,9 +8,8 @@ from sqlalchemy.exc import IntegrityError from app.db import session as db_session -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config -from tests.checkers.execution.historical_execution import historical_lease, historical_reserve +from tests.checkers.execution.historical_execution import seed_archived_running_checker, seed_archived_checker_request from tests.checkers.execution.test_material_migration import retained_snapshot from tests.historical_submission_fixtures import historical_material_fixture @@ -29,14 +28,12 @@ async def test_actual_upgrade_preserves_or_refuses_without_repair( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0009_checker_material_lineage") - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_material_fixture( - tmp_path, isolated_database_env, provision_checker=False + tmp_path, isolated_database_env ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) - await historical_reserve(h) + await seed_archived_checker_request(h) if unprovable_receipt: - await historical_lease(h) + await seed_archived_running_checker(h) before = await retained_snapshot(h.factory) if unprovable_receipt: with pytest.raises(IntegrityError, match="retained checker authorization receipts are unprovable"): @@ -64,12 +61,10 @@ async def test_upgrade_excludes_writer_across_receipt_preflight( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0009_checker_material_lineage") - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_material_fixture( - tmp_path, isolated_database_env, provision_checker=False + tmp_path, isolated_database_env ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) - reserved = await historical_reserve(h) + reserved = await seed_archived_checker_request(h) scanned, resume = threading.Event(), threading.Event() execute = Operations.execute @@ -93,7 +88,7 @@ async def write_predecessor_receipt(): worker_lease_id=:lease,worker_lease_generation=1, worker_lease_expires_at=clock_timestamp()+interval '300 seconds', execute_evidence_id=:receipt,started_at=clock_timestamp() WHERE id=:id"""), - {"lease":new_record_id(),"receipt":new_record_id(),"id":reserved.attempt_id}) + {"lease":new_record_id(),"receipt":new_record_id(),"id":reserved["attempt_id"]}) try: assert await asyncio.to_thread(scanned.wait, 10) @@ -114,13 +109,13 @@ async def write_predecessor_receipt(): with pytest.raises(IntegrityError, match="receipt custody|foreign key"): await asyncio.wait_for(writer, 10) async with h.factory() as session: - row = (await session.execute(text("SELECT status,execute_evidence_id,worker_lease_id FROM checker_runs WHERE id=:id"), {"id": reserved.attempt_id})).one() + row = (await session.execute(text("SELECT status,execute_evidence_id,worker_lease_id FROM checker_runs WHERE id=:id"), {"id": reserved["attempt_id"]})).one() assert row == ("queued", None, None) from tests.checkers.execution.support import provision_post_submit_service await provision_post_submit_service(h.factory) - valid = await historical_lease(h) - assert valid.reservation.attempt_id == reserved.attempt_id + valid = await seed_archived_running_checker(h) + assert valid["reservation"]["attempt_id"] == reserved["attempt_id"] finally: resume.set() await asyncio.gather(migration, *([writer] if writer else []), return_exceptions=True) diff --git a/backend/tests/authorization/post_submit/test_receipt_custody.py b/backend/tests/authorization/post_submit/test_receipt_custody.py index 4fc4d7dcd..458aaa3c3 100644 --- a/backend/tests/authorization/post_submit/test_receipt_custody.py +++ b/backend/tests/authorization/post_submit/test_receipt_custody.py @@ -15,7 +15,7 @@ ) from app.modules.checkers.execution_repository import ExecutionRepository, reservation from app.modules.checkers.models import CheckerRun, CheckerResult -from app.modules.checkers.post_submit_contracts import make_post_submit_result +from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_result from app.modules.outbox.models import OutboxEvent from app.modules.tasks.models import AuditEvent from tests.checkers.execution.support import live_executor, reserve @@ -35,6 +35,24 @@ async def snapshot(h): ) +async def claim_expiring_lease(h, monkeypatch): + """Claim a genuine ten-second lease, restoring owner budgets before takeover.""" + import app.modules.checkers.execution as execution + + executor = live_executor(h) + with monkeypatch.context() as patch: + patch.setattr(executor, "_execution_budget_seconds", lambda _request: 1) + patch.setattr(execution, "SELECTED_POST_FINALIZATION_MARGIN_MS", 9_000) + lease, replay = await executor._claim(h.request) + assert replay is None + async with h.factory() as session: + assert await session.scalar(text( + "SELECT EXTRACT(EPOCH FROM worker_lease_expires_at-started_at) " + "FROM public.checker_runs WHERE id=:id" + ), {"id": lease.reservation.attempt_id}) == 10 + return lease + + async def next_lease(h, borrowed=None, *, shadow=False): """Simulate a faulty owner attaching a different real receipt after AUTH consumption.""" async with h.factory() as session, session.begin(): @@ -71,11 +89,9 @@ async def next_lease(h, borrowed=None, *, shadow=False): ["previous_lease", "foreign_attempt", "material_read", "nonexistent", "shadow_tables"], ) async def test_execute_receipt_substitution_rejected(tmp_path, isolated_database_env, substitution, monkeypatch): - import app.modules.checkers.execution as execution - monkeypatch.setattr(execution, "LEASE_SECONDS", 5) async with material_fixture(tmp_path / "one", isolated_database_env) as h: await reserve(h) - lease, _ = await live_executor(h)._claim(h.request) + lease = await claim_expiring_lease(h, monkeypatch) async with h.factory() as session: run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) borrowed = UUID(run.execute_evidence_id) @@ -108,13 +124,12 @@ async def test_execute_receipt_substitution_rejected(tmp_path, isolated_database async def test_final_receipt_commits_only_exact_outcome( tmp_path, isolated_database_env, substitution, monkeypatch ): - import app.modules.checkers.execution as execution - - if substitution == "execute_chain": - monkeypatch.setattr(execution, "LEASE_SECONDS", 1) async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - lease, _ = await live_executor(h)._claim(h.request) + if substitution == "execute_chain": + lease = await claim_expiring_lease(h, monkeypatch) + else: + lease, _ = await live_executor(h)._claim(h.request) old_execute = None if substitution == "execute_chain": async with h.factory() as session: @@ -122,16 +137,16 @@ async def test_final_receipt_commits_only_exact_outcome( await wait_for_expiry(h, lease) lease = await next_lease(h) source = await final_facts(h, lease) - result = make_post_submit_result( + result = make_selected_post_submit_result( request_id=h.request.evaluation_request_id, request_digest=h.request.request_sha256, attempt_id=lease.reservation.attempt_id, result_id=lease.reservation.result_id, evaluation_generation=h.request.evaluation_generation, outcome="infrastructure_failed", - member_results=(), infrastructure_failure_code="deadline_exceeded", + execution=None, infrastructure_failure_code="deadline_exceeded", ) facts = source.model_copy(update={"result": result}) stored = facts if substitution == "result": - changed = make_post_submit_result(**(result.model_dump(exclude={"result_digest"}) | { + changed = make_selected_post_submit_result(**(result.model_dump(exclude={"result_digest"}) | { "infrastructure_failure_code": "invalid_output", })) stored = facts.model_copy(update={"result": changed}) @@ -167,6 +182,8 @@ async def wait_for_expiry(h, lease): await session.execute(text( "select pg_sleep(greatest(0,extract(epoch from cast(:expiry as timestamptz)-clock_timestamp()))+0.02)" ), {"expiry": lease.expires_at}) + assert await session.scalar(text("SELECT clock_timestamp()>=:expiry"), + {"expiry": lease.expires_at}) @pytest.mark.parametrize("outcome", ["completed", "infrastructure_failed"]) @@ -181,14 +198,14 @@ async def test_terminal_receipt_digest_matches_database(tmp_path, isolated_datab update={ "material": None, "input_materialization_evidence_id": None, - "result": make_post_submit_result( + "result": make_selected_post_submit_result( request_id=h.request.evaluation_request_id, request_digest=h.request.request_sha256, attempt_id=lease.reservation.attempt_id, result_id=lease.reservation.result_id, evaluation_generation=h.request.evaluation_generation, outcome=outcome, - member_results=(), + execution=None, infrastructure_failure_code="material_unavailable", ), } @@ -218,14 +235,14 @@ async def test_final_receipt_independently_requires_execute_receipt( update={ "material": None, "input_materialization_evidence_id": None, - "result": make_post_submit_result( + "result": make_selected_post_submit_result( request_id=h.request.evaluation_request_id, request_digest=h.request.request_sha256, attempt_id=lease.reservation.attempt_id, result_id=lease.reservation.result_id, evaluation_generation=h.request.evaluation_generation, outcome="infrastructure_failed", - member_results=(), + execution=None, infrastructure_failure_code="material_unavailable", ), } diff --git a/backend/tests/authorization/post_submit/test_timeout.py b/backend/tests/authorization/post_submit/test_timeout.py index 61954f3fd..52656f64c 100644 --- a/backend/tests/authorization/post_submit/test_timeout.py +++ b/backend/tests/authorization/post_submit/test_timeout.py @@ -12,6 +12,22 @@ from .test_receipt_custody import snapshot +async def wait_for_boundary(task, entered): + """Surface an early executor failure instead of hiding it behind the waiter.""" + waiter = asyncio.create_task(entered.wait()) + try: + done, _ = await asyncio.wait( + (task, waiter), timeout=10, return_when=asyncio.FIRST_COMPLETED, + ) + if task in done: + await task + raise AssertionError("executor completed before the controlled boundary") + assert waiter in done, "executor did not reach the controlled boundary" + finally: + waiter.cancel() + await asyncio.gather(waiter, return_exceptions=True) + + @pytest.mark.parametrize("consumer_failure", ["cancel", "runtime_error", "timeout_error"]) @pytest.mark.parametrize("change", ["revoked", "replica_unavailable", "unchanged"]) async def test_outer_deadline_revalidates_material_after_cleanup(tmp_path, isolated_database_env, monkeypatch, change, consumer_failure): @@ -29,13 +45,13 @@ async def paused(consumer, request, material): error = RuntimeError if consumer_failure == "runtime_error" else TimeoutError raise error("consumer failed after abort") from None raise - monkeypatch.setattr(execution._StructuralConsumer, "evaluate", paused) + monkeypatch.setattr(execution._SelectedExternalConsumer, "evaluate", paused) # Arm the actual asyncio deadline after the consumer enters, avoiding a # machine-speed race between setup queries and the short test deadline. deadlines = controlled_deadline(monkeypatch, execution) task = asyncio.create_task(live_executor(h).evaluate_post_submission(h.request)) try: - await asyncio.wait_for(entered.wait(), 10) + await wait_for_boundary(task, entered) before = await snapshot(h) if change == "revoked": await service_link_state(h.factory, ServiceIdentity.ARTIFACT_MATERIALIZER, active=False) @@ -68,13 +84,16 @@ async def paused(consumer, request, material): def controlled_deadline(monkeypatch, execution): """Use the real timeout, armed only after the boundary under test is reached.""" + # This budget also determines the durable lease. Keep its finalization margin + # inside the installed one-hour lease guard while disarming only this timer. + controlled_seconds = 300 original_timeout, deadlines = asyncio.timeout, [] def timeout(seconds): - timer = original_timeout(None if seconds == 12345 else seconds) - if seconds == 12345: + timer = original_timeout(None if seconds == controlled_seconds else seconds) + if seconds == controlled_seconds: deadlines.append(timer) return timer - monkeypatch.setattr(execution, "EXECUTION_TIMEOUT_SECONDS", 12345) + monkeypatch.setattr(execution.PostSubmissionExecutor, "_execution_budget_seconds", staticmethod(lambda request: controlled_seconds)) monkeypatch.setattr(asyncio, "timeout", timeout) return deadlines @@ -110,7 +129,7 @@ def failed_workspace(name): if failure == "release": monkeypatch.setattr(h.manager, "release", failed_release) else: - monkeypatch.setattr(execution._StructuralConsumer, "evaluate", paused_consumer) + monkeypatch.setattr(execution._SelectedExternalConsumer, "evaluate", paused_consumer) if failure == "workspace": monkeypatch.setattr(h.manager, "_cleanup_workspace_sync", failed_workspace) else: @@ -118,7 +137,7 @@ def failed_workspace(name): deadlines = controlled_deadline(monkeypatch, execution) task = asyncio.create_task(live_executor(h).evaluate_post_submission(h.request)) try: - await asyncio.wait_for(entered.wait(), 10) + await wait_for_boundary(task, entered) before = await snapshot(h) deadlines[0].reschedule(asyncio.get_running_loop().time()) for _ in range(10): diff --git a/backend/tests/authorization/post_submit_routing/__init__.py b/backend/tests/authorization/post_submit_routing/__init__.py new file mode 100644 index 000000000..1ccdb3f3a --- /dev/null +++ b/backend/tests/authorization/post_submit_routing/__init__.py @@ -0,0 +1 @@ +"""Exact post-submit routing authority contract tests.""" diff --git a/backend/tests/authorization/pre_submit/__init__.py b/backend/tests/authorization/pre_submit/__init__.py new file mode 100644 index 000000000..a4c68eeaa --- /dev/null +++ b/backend/tests/authorization/pre_submit/__init__.py @@ -0,0 +1 @@ +"""Selected PRE authority contract tests.""" diff --git a/backend/tests/authorization/pre_submit/test_contracts.py b/backend/tests/authorization/pre_submit/test_contracts.py new file mode 100644 index 000000000..80217b081 --- /dev/null +++ b/backend/tests/authorization/pre_submit/test_contracts.py @@ -0,0 +1,174 @@ +"""Selected PRE fixed-service resources bind exact phase and durable request facts.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +import pytest + +from app.core.identifiers import new_record_id +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.authorization.catalogue import PermissionId +from app.modules.authorization.domain.audit import ( + AuthorizationDecision, + MatchedAuthorityKind, +) +from app.modules.authorization.domain.pre_submit import ( + EXECUTE, + FINALIZE, + PreSubmitResourceContext, + parse_pre_submit_prepare, + pre_submit_prepare_matches, + pre_submit_prepare_values, + pre_submit_resource, +) +from app.modules.authorization.domain.action_groups import CONTEXT_DIGEST_ACTIONS +from app.modules.authorization.domain.resource_digest import authorization_resource_digest +from app.modules.authorization.runtime import PreparedAuthorizationHandleInvalid +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, +) +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitExecuteFacts, + SelectedPreSubmitFinalizeFacts, + SelectedPreSubmitLeaseFacts, + SelectedPreSubmitReservationFacts, +) + + +def _reservation() -> SelectedPreSubmitReservationFacts: + return SelectedPreSubmitReservationFacts( + actor_profile_id=new_record_id(), + identity_link_id=new_record_id(), + project_id=new_record_id(), + task_id=new_record_id(), + assignment_id=new_record_id(), + attempt_id=new_record_id(), + idempotency_key=new_record_id(), + attempt_request_digest="sha256:" + "0" * 64, + prepared_generation_id=new_record_id(), + policy_sha256="sha256:" + "1" * 64, + default_behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + default_input_sha256="sha256:" + "2" * 64, + archive_sha256="sha256:" + "3" * 64, + archive_byte_count=100, + semantic_manifest_sha256="sha256:" + "4" * 64, + execution_budget_ms=60_000, + ) + + +def _execute() -> SelectedPreSubmitExecuteFacts: + return SelectedPreSubmitExecuteFacts( + SelectedPreSubmitLeaseFacts( + reservation=_reservation(), + lease_id=new_record_id(), + lease_generation=1, + lease_expires_at=datetime.now(UTC) + timedelta(minutes=1), + ) + ) + + +def test_selected_pre_resource_binds_phase_project_run_and_request() -> None: + execute = _execute() + resource = pre_submit_resource(EXECUTE, execute) + prepared = pre_submit_prepare_values(execute.lease.reservation) + + assert resource.resource_id == execute.lease.reservation.attempt_id + assert resource.scope_project_id == execute.lease.reservation.project_id + assert authorization_resource_digest(resource) == execute.facts_digest + assert {EXECUTE, FINALIZE} <= CONTEXT_DIGEST_ACTIONS + assert pre_submit_prepare_matches(EXECUTE, prepared, resource) + assert not pre_submit_prepare_matches( + EXECUTE, + prepared | {"request_digest": "sha256:" + "f" * 64}, + resource, + ) + with pytest.raises(ValueError, match="phase"): + PreSubmitResourceContext( + resource_id=resource.resource_id, + scope_project_id=resource.scope_project_id, + action_id=FINALIZE, + facts=execute, + ) + + +def test_selected_pre_resource_is_admitted_by_decision_and_audit_boundaries() -> None: + execute = _execute() + resource = pre_submit_resource(EXECUTE, execute) + decision_id = new_record_id() + decision = AuthorizationDecision( + decision_id=decision_id, + action_id=EXECUTE, + permission_id=PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + allowed=True, + denial_code=None, + resource_type=resource.resource_type, + resource_id=resource.resource_id, + resource_context_digest=execute.facts_digest, + matched_authority_kind=MatchedAuthorityKind.FIXED_SERVICE, + revalidated=True, + request_id=new_record_id(), + correlation_id=new_record_id(), + ) + + event = AuthorityAuditEventInput( + event_id=decision_id, + event_type=AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + entity_type="authorization_decision", + entity_id=str(decision_id), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(execute.lease.reservation.actor_profile_id), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + permission_id=decision.permission_id, + action_id=decision.action_id, + project_id=str(resource.scope_project_id), + resource_type=resource.resource_type, + resource_id=str(resource.resource_id), + target_ref_kind="project", + target_ref_id=str(resource.scope_project_id), + reason="authorization_evaluation", + after_facts={ + "allowed": True, + "resource_context_digest": decision.resource_context_digest, + }, + ) + + assert event.resource_type == "selected_pre_submit_run" + + +def test_selected_pre_finalize_and_prepare_parser_are_closed() -> None: + execute = _execute() + final = SelectedPreSubmitFinalizeFacts( + lease=execute.lease, + execution=None, + infrastructure_failure_code="deadline_exceeded", + ) + resource = pre_submit_resource(FINALIZE, final) + prepared = pre_submit_prepare_values(execute.lease.reservation) + + assert pre_submit_prepare_matches(FINALIZE, prepared, resource) + assert parse_pre_submit_prepare( + FINALIZE, prepared, PreparedAuthorizationHandleInvalid + ) == prepared + with pytest.raises( + PreparedAuthorizationHandleInvalid, + match="invalid prepared selected PRE authority", + ): + parse_pre_submit_prepare( + FINALIZE, + prepared | {"extra": "value"}, + PreparedAuthorizationHandleInvalid, + ) + changed = replace( + execute.lease.reservation, + attempt_id=new_record_id(), + ) + assert not pre_submit_prepare_matches( + FINALIZE, pre_submit_prepare_values(changed), resource + ) diff --git a/backend/tests/authorization/setup_finalization/test_catalogue.py b/backend/tests/authorization/setup_finalization/test_catalogue.py index a849c84f0..97d7ed9fa 100644 --- a/backend/tests/authorization/setup_finalization/test_catalogue.py +++ b/backend/tests/authorization/setup_finalization/test_catalogue.py @@ -28,6 +28,121 @@ from .test_prepared import legacy_resource +EXPECTED_ACTIVE_ACTIONS = frozenset({ + ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT, + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.OPERATIONS_CHECKER_RETRY, + ActionId.CHECKER_REGISTRY_REGISTER, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.TASK_SUBMISSION_LIST, ActionId.TASK_GUIDE_READ, + 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, + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionId.OUTBOX_DISPATCH, + ActionId.TASK_POST_SUBMIT_ROUTE, + ActionId.PROJECT_GUIDE_ACTIVATE, + 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.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + 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_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, + ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + ActionId.PROJECT_READ, + 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_GUIDE_SOURCE_INGEST, + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + ActionId.SUBMISSION_CREATE, + ActionId.TASK_CLAIM, ActionId.TASK_START, + ActionId.TASK_WORK_CONTEXT_READ, + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, + ActionId.TASK_READ, + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.PROJECT_TASK_READ, + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, + ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, + ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, + ActionId.AUDIT_TASK_EVIDENCE_READ, + ActionId.TASK_QUEUE_READ, + ActionId.PROJECT_TASK_QUEUE_READ, + ActionId.OPERATIONS_TASK_QUEUE_READ, + ActionId.PROJECT_TASK_CREATE, + ActionId.PROJECT_TASK_SCREEN, + ActionId.PROJECT_TASK_RELEASE, + ActionId.OPERATIONS_TASK_START_OVERRIDE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.COMPENSATION_ADAPTER_BINDING_READ, + ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, + ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, + ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, + ActionId.CONTRIBUTION_POLICY_READ, + ActionId.CONTRIBUTION_POLICY_CREATE_DRAFT, + ActionId.CONTRIBUTION_POLICY_UPDATE_DRAFT, + ActionId.CONTRIBUTION_POLICY_PUBLISH, + ActionId.CONTRIBUTION_POLICY_RETIRE, +}) + + @pytest.mark.parametrize("identity", list(ServiceIdentity)) async def test_finalization_action_service_matrix(monkeypatch, identity): case = Case(monkeypatch, identity=identity) @@ -128,115 +243,4 @@ def test_exact_active_action_inventory(): definition.action_id for definition in ACTION_DEFINITIONS if definition.availability is ActionAvailability.ACTIVE - } == { - ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT, - ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, - ActionId.OPERATIONS_CHECKER_RETRY, - ActionId.CHECKER_REGISTRY_REGISTER, - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.TASK_SUBMISSION_LIST, ActionId.TASK_GUIDE_READ, - 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, - ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionId.OUTBOX_DISPATCH, - ActionId.TASK_POST_SUBMIT_ROUTE, - ActionId.PROJECT_GUIDE_ACTIVATE, - 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.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - 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_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, - ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, - ActionId.PROJECT_READ, - 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_GUIDE_SOURCE_INGEST, - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, - ActionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionId.ARTIFACT_PENDING_WORK_SCAN, - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, - ActionId.SUBMISSION_CREATE, - ActionId.TASK_CLAIM, ActionId.TASK_START, - ActionId.TASK_WORK_CONTEXT_READ, - ActionId.PROJECT_TASK_WORK_CONTEXT_READ, - ActionId.TASK_READ, - ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.PROJECT_TASK_READ, - ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, - ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, - ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, - ActionId.AUDIT_TASK_EVIDENCE_READ, - ActionId.TASK_QUEUE_READ, - ActionId.PROJECT_TASK_QUEUE_READ, - ActionId.OPERATIONS_TASK_QUEUE_READ, - ActionId.PROJECT_TASK_CREATE, - ActionId.PROJECT_TASK_SCREEN, - ActionId.PROJECT_TASK_RELEASE, - ActionId.OPERATIONS_TASK_START_OVERRIDE, - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, - ActionId.COMPENSATION_ADAPTER_BINDING_READ, - ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, - ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, - ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, - ActionId.CONTRIBUTION_POLICY_READ, - ActionId.CONTRIBUTION_POLICY_CREATE_DRAFT, - ActionId.CONTRIBUTION_POLICY_UPDATE_DRAFT, - ActionId.CONTRIBUTION_POLICY_PUBLISH, - ActionId.CONTRIBUTION_POLICY_RETIRE, - } + } == EXPECTED_ACTIVE_ACTIONS diff --git a/backend/tests/authorization/submission_history/test_privacy.py b/backend/tests/authorization/submission_history/test_privacy.py index d263c392a..2c926aeb7 100644 --- a/backend/tests/authorization/submission_history/test_privacy.py +++ b/backend/tests/authorization/submission_history/test_privacy.py @@ -1,6 +1,5 @@ """Fixed SQL projections and live grant privacy over retained checker results.""" -import json from uuid import uuid4 import pytest @@ -11,6 +10,7 @@ from app.modules.tasks.models import AuditEvent, Submission from app.modules.tasks.submission_history import SubmissionHistoryRepository from app.modules.checkers.history import CheckerHistoryRepository +from tests.checkers.external.support import post_registry_spec from tests.submission_fixtures import seed_retained_submission, seed_retained_checker_run from tests.test_tasks import (create_active_project, create_started_task, complete_submission_payload, set_dev_actor, auth_headers, actor_id) @@ -19,16 +19,14 @@ RESULT_FIELDS, MANAGER_RESULT_FIELDS, EVIDENCE_FIELDS) -@pytest.mark.parametrize("state,failures", [("completed", ()), ("completed", ("check_policy_context_present",)), ("infrastructure_failed", ())]) -async def test_fixed_projection_and_selected_columns(task_client, monkeypatch, state, failures): - project = await create_active_project(task_client) +@pytest.mark.parametrize("state,verdict", [("completed", "passed"), ("completed", "failed"), ("infrastructure_failed", "passed")]) +async def test_fixed_projection_and_selected_columns(task_client, monkeypatch, state, verdict): + project = await create_active_project(task_client, post_submit_registry_spec=post_registry_spec()) task = await create_started_task(task_client, project["id"], monkeypatch) submission = await seed_retained_submission(task["id"], complete_submission_payload()) - run = await seed_retained_checker_run(submission, state=state, failures=failures) - from app.modules.checkers.models import CheckerRun + run = await seed_retained_checker_run(submission, state=state, verdict=verdict) async with db_session.get_session_factory()() as session: - stored = await session.get(CheckerRun, run) - private_attestation = json.loads(stored.request_json)["structural_input"]["worker_attestation"] + private_attestation = (await session.get(Submission, submission)).worker_attestation assert private_attestation captured = [] def capture(conn, cursor, statement, parameters, context, executemany): @@ -54,7 +52,7 @@ def capture(conn, cursor, statement, parameters, context, executemany): nested_fields = (MANAGER_RESULT_FIELDS if manager else RESULT_FIELDS) if checker else EVIDENCE_FIELDS assert all(set(nested) == nested_fields for nested in item[nested_key]) if checker: - assert bool(item["results"]) == (state == "completed" and (manager or not failures)) + assert bool(item["results"]) == (state == "completed") # Actual selected columns, rather than searching predicates that # legitimately compute a boolean inside PostgreSQL. No raw body, # URI/hash, aggregate count, or receipt is loaded into Python. @@ -102,18 +100,18 @@ async def forbidden(*args, **kwargs): async def test_nested_values_match_exact_stored_parents(task_client, monkeypatch, manager): from app.modules.tasks.models import EvidenceItem from app.modules.checkers.models import CheckerResult - from app.modules.checkers.execution_results import RESULT_MESSAGES - project = await create_active_project(task_client) + from app.modules.checkers.execution_results import RESULT_STATUS_MESSAGES + project = await create_active_project(task_client, post_submit_registry_spec=post_registry_spec()) task = await create_started_task(task_client, project["id"], monkeypatch) submission = await seed_retained_submission(task["id"], complete_submission_payload()) run = await seed_retained_checker_run(submission) - sibling = await seed_retained_checker_run(submission, generation=2, failures=("check_submission_packet",)) + sibling = await seed_retained_checker_run(submission, generation=2, verdict="failed") foreign_task = await create_started_task(task_client, project["id"], monkeypatch, subject="foreign-nested-owner") payload = complete_submission_payload() for item in payload["evidence_items"]: item["label"] = "Foreign " + item["label"] foreign_submission = await seed_retained_submission(foreign_task["id"], payload) - foreign_run = await seed_retained_checker_run(foreign_submission, failures=("check_evidence_present",)) + foreign_run = await seed_retained_checker_run(foreign_submission, verdict="failed") async with db_session.get_session_factory()() as session: evidence = list(await session.scalars(select(EvidenceItem).order_by(EvidenceItem.id))) rows = list(await session.scalars(select(CheckerResult).order_by(CheckerResult.member_order))) @@ -127,11 +125,11 @@ async def test_nested_values_match_exact_stored_parents(task_client, monkeypatch if row.checker_run_id != run_id: continue assert row.task_id == task["id"] and row.submission_id == submission - message, fix = RESULT_MESSAGES[row.code] + message, fix = RESULT_STATUS_MESSAGES[row.status] entry = {field:getattr(row,field) for field in ("id","checker_name","status","severity")} entry.update(worker_message=message, worker_suggested_fix=fix) if manager: - entry.update(message=message, blocks_review=row.status=="failed" and row.severity in {"high","critical"}) + entry.update(message=message, blocks_review=row.status=="failed") expected_results[run_id].append(entry) assert expected_results[run] != expected_results[sibling] and expected_evidence set_dev_actor(monkeypatch, roles="", subject="project-manager-subject" if manager else "worker-one") diff --git a/backend/tests/authorization/submission_history/test_reads.py b/backend/tests/authorization/submission_history/test_reads.py index a5ab2bbb9..5343956ed 100644 --- a/backend/tests/authorization/submission_history/test_reads.py +++ b/backend/tests/authorization/submission_history/test_reads.py @@ -10,6 +10,7 @@ from app.modules.tasks.models import AuditEvent, Submission from app.modules.checkers.models import CheckerRun from app.modules.authorization.models import ProjectRoleGrant, AdminRoleGrant +from tests.checkers.external.support import post_registry_spec from tests.submission_fixtures import seed_retained_submission, seed_retained_checker_run from tests.test_tasks import ( create_active_project, create_started_task, complete_submission_payload, @@ -18,7 +19,7 @@ async def history_case(client, monkeypatch, *, run_status="completed"): - project = await create_active_project(client) + project = await create_active_project(client, post_submit_registry_spec=post_registry_spec()) task = await create_started_task(client, project["id"], monkeypatch) submission = await seed_retained_submission(task["id"], complete_submission_payload()) run = await seed_retained_checker_run(submission, state=run_status) diff --git a/backend/tests/authorization/task_authority/test_submission_policy.py b/backend/tests/authorization/task_authority/test_submission_policy.py index 29abf9a10..08dd1436a 100644 --- a/backend/tests/authorization/task_authority/test_submission_policy.py +++ b/backend/tests/authorization/task_authority/test_submission_policy.py @@ -307,18 +307,5 @@ async def test_submission_rejects_crossed_post_submit_policy_sidecar( assert task.status == "in_progress" assert submissions == [] assert task.locked_post_submit_checker_policy_body == locked_body - assert "check_acceptance_criteria_present" not in [ - entry["checker_id"] - for entry in locked_body["entries"] - if entry["classification"] == "project_required" - ] - assert "check_acceptance_criteria_present" not in [ - entry["checker_id"] for entry in locked_body["entries"] - ] - assert "check_required_files" in [ - entry["checker_id"] - for entry in locked_body["entries"] - if entry["classification"] == "platform_default" - ] - assert "check_required_files" in [entry["checker_id"] for entry in locked_body["entries"]] + assert checker_runs == [] diff --git a/backend/tests/authorization/task_locked_context/test_visibility.py b/backend/tests/authorization/task_locked_context/test_visibility.py index 3300c8e0b..68565b642 100644 --- a/backend/tests/authorization/task_locked_context/test_visibility.py +++ b/backend/tests/authorization/task_locked_context/test_visibility.py @@ -1,17 +1,15 @@ """Historical locked facts remain readable in every stored lifecycle state.""" -import json - import pytest from sqlalchemy import select from app.db import session as db_session from app.modules.authorization.models import AdminRoleGrant -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy +from app.modules.checkers.api.external_policy import validate_stored_external_post_policy from app.modules.tasks.lifecycle import ALLOWED_TASK_TRANSITIONS from app.modules.tasks.models import WorkstreamTask from tests.authorization.task_locked_context.support import KINDS, grant_for, path from tests.authorization.task_reads.support import task_case -from tests.tasks.test_locked_context import REFERENCE_FIELDS, SUMMARY, SUMMARY_FIELDS +from tests.tasks.test_locked_context import REFERENCE_FIELDS, SUMMARY @pytest.mark.parametrize("kind", KINDS) @@ -23,9 +21,11 @@ async def test_locked_context_exact_fields_all_states_and_corrupt_custody(admin_ stored = await session.get(WorkstreamTask, str(task)) expected = {field: str(getattr(stored, "id" if field == "task_id" else field)) if field.endswith("_id") else getattr(stored, field) for field in REFERENCE_FIELDS} - compiled = CompiledPostSubmitPolicy.model_validate_json(json.dumps(stored.locked_post_submit_checker_policy_body)) - summary = {field: getattr(compiled, field) for field in SUMMARY_FIELDS} - summary["blocking_severities"] = list(compiled.blocking_severities) + compiled = validate_stored_external_post_policy(stored.locked_post_submit_checker_policy_body) + required = [item.registry.capability_id for item in compiled.entries] + summary = dict(schema_version=compiled.schema_version, default_checkers=[], + required_checkers=required, warning_checkers=[], + execution_checkers=required, blocking_severities=["critical", "high"]) expected |= {SUMMARY: summary} if kind == "management" else {} states = {state for transition in ALLOWED_TASK_TRANSITIONS for state in transition} assert len(states) == 9 @@ -40,9 +40,9 @@ async def test_locked_context_exact_fields_all_states_and_corrupt_custody(admin_ assert response.json() == expected async with factory() as session, session.begin(): stored = await session.get(WorkstreamTask, str(task)) - stored.locked_post_submit_checker_policy_body = {**stored.locked_post_submit_checker_policy_body, "blocking_severities": []} + stored.locked_post_submit_checker_policy_body = {**stored.locked_post_submit_checker_policy_body, "policy_sha256": "sha256:" + "0" * 64} async with factory() as independent: - assert (await independent.get(WorkstreamTask, str(task))).locked_post_submit_checker_policy_body["blocking_severities"] == [] + assert (await independent.get(WorkstreamTask, str(task))).locked_post_submit_checker_policy_body["policy_sha256"] == "sha256:" + "0" * 64 response = await admin_access.signed.client.get(path(kind, project, task), headers=admin_access.target.headers) assert response.status_code == 422, response.text assert response.json()["error"]["code"] == "task_locked_context_invalid" diff --git a/backend/tests/authorization/test_assignment_invalidation_contract.py b/backend/tests/authorization/test_assignment_invalidation_contract.py index 181b1a6dc..ae37061b9 100644 --- a/backend/tests/authorization/test_assignment_invalidation_contract.py +++ b/backend/tests/authorization/test_assignment_invalidation_contract.py @@ -104,13 +104,16 @@ def test_fixed_service_bindings_preserve_both_exact_contracts(): assignment = prepared_fixed_service_bindings( ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, resource.model_dump(mode="json"), ValueError, ) - assert assignment == {"assignment_invalidation_context": resource, "outbox_dispatch_digest": None, "post_submit_prepare_context": None} + assert assignment == {"assignment_invalidation_context": resource, "outbox_dispatch_digest": None, + "post_submit_prepare_context": None, "pre_submit_prepare_context": None} digest = "sha256:" + "a" * 64 assert prepared_fixed_service_bindings(ActionId.OUTBOX_DISPATCH, {"outbox_dispatch_digest": digest}, ValueError) == { - "assignment_invalidation_context": None, "outbox_dispatch_digest": digest, "post_submit_prepare_context": None, + "assignment_invalidation_context": None, "outbox_dispatch_digest": digest, + "post_submit_prepare_context": None, "pre_submit_prepare_context": None, } assert prepared_fixed_service_bindings(ActionId.ACTOR_PROFILE_READ_SELF, {}, ValueError) == { - "assignment_invalidation_context": None, "outbox_dispatch_digest": None, "post_submit_prepare_context": None, + "assignment_invalidation_context": None, "outbox_dispatch_digest": None, + "post_submit_prepare_context": None, "pre_submit_prepare_context": None, } for action, message in ( (ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, "invalid assignment reconciliation authority"), diff --git a/backend/tests/authorization/test_catalogue.py b/backend/tests/authorization/test_catalogue.py index 995666ac5..f1ad97b43 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -26,7 +26,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> 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) == 150 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 152 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -105,7 +105,7 @@ 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: 115, + ActionAvailability.ACTIVE: 117, ActionAvailability.PLANNED: 35, } assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF @@ -132,7 +132,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 == 78 + assert permission_response.total == 80 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) @@ -147,7 +147,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())) == 28 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 30 assert FUTURE_INTENT_REQUIRED_ACTIONS == { ActionId.REVIEW_FINDING_EVIDENCE_INGEST, ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, @@ -204,6 +204,8 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> def test_active_fixed_service_actions_are_exact(): """Only the explicitly active fixed-service actions are executable.""" active_internal = { + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, diff --git a/backend/tests/authorization/test_fixed_service_action_context.py b/backend/tests/authorization/test_fixed_service_action_context.py index 5115c7184..7690dec25 100644 --- a/backend/tests/authorization/test_fixed_service_action_context.py +++ b/backend/tests/authorization/test_fixed_service_action_context.py @@ -93,7 +93,8 @@ async def test_submission_binding_validation_is_concealed() -> None: """Malformed exact binding facts never expose Pydantic validation details.""" ids = [uuid4() for _ in range(12)] facts = SubmissionBindingAuthorityFacts( - admission_id=ids[0], evidence_set_id=ids[1], actor_profile_id=ids[2], + admission_id=ids[0], evidence_set_id=ids[1], selected_pre_submit_run_id=None, + actor_profile_id=ids[2], identity_link_id=ids[3], project_id=ids[4], task_id=ids[5], assignment_id=ids[6], predecessor_submission_id=None, predecessor_submission_version=None, submission_id=ids[7], diff --git a/backend/tests/checker_output_admission_helpers.py b/backend/tests/checker_output_admission_helpers.py index a8843f535..e9376dd50 100644 --- a/backend/tests/checker_output_admission_helpers.py +++ b/backend/tests/checker_output_admission_helpers.py @@ -26,10 +26,9 @@ CheckerOutputReservation, CheckerOutputSlot, ) +from app.modules.checkers.execution_repository import stored_request from app.modules.checkers.models import CheckerRun -from app.modules.checkers.api.post_submit import make_post_submit_request from app.modules.tasks.models import Submission, WorkstreamTask -from tests.checkers.post_submit.support import request as checker_evaluation_request async def make_checker_output_reservation( @@ -54,15 +53,12 @@ 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 - seed = checker_evaluation_request(project_id=UUID(task.project_id)) - fields = seed.model_dump(exclude={"request_sha256"}) - fields.update( - task_id=UUID(task.id), - assignment_id=UUID(submission.task_assignment_id), - submission_id=UUID(submission.id), - submission_version=submission.version, - ) - evaluation = make_post_submit_request(**fields) + 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), + ) return CheckerOutputReservation( evaluation=evaluation, checker_run_id=UUID(checker_run.id), diff --git a/backend/tests/checkers/default/test_contract.py b/backend/tests/checkers/default/test_contract.py new file mode 100644 index 000000000..0df7ffa4c --- /dev/null +++ b/backend/tests/checkers/default/test_contract.py @@ -0,0 +1,105 @@ +"""Contract proof for the one Workstream-owned default checker.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_MANIFEST, + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + WORKSTREAM_DEFAULT_CHECK_IDS, + WorkstreamDefaultCheck, + WorkstreamDefaultCheckerResult, +) +from app.core.hashing import canonical_json_hash + + +def _passing_result() -> WorkstreamDefaultCheckerResult: + return WorkstreamDefaultCheckerResult( + behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + eligible=True, + checks=tuple( + WorkstreamDefaultCheck( + check_id=check_id, + status="passed", + failure_code=None, + finding_count=0, + ) + for check_id in WORKSTREAM_DEFAULT_CHECK_IDS + ), + warnings=(), + ) + + +def test_behavior_identity_and_exact_four_check_order_are_stable() -> None: + result = _passing_result() + + assert WORKSTREAM_DEFAULT_BEHAVIOR_SHA256 == ( + "sha256:af2c604f792c4456d3b222036c772161b1503eace65ff90c3d3a23d9d348c28f" + ) + assert tuple(item.check_id for item in result.checks) == WORKSTREAM_DEFAULT_CHECK_IDS + + for changed in ( + result.model_copy(update={"checks": result.checks[:-1]}), + result.model_copy(update={"checks": tuple(reversed(result.checks))}), + result.model_copy(update={"behavior_sha256": "sha256:" + "0" * 64}), + ): + with pytest.raises(ValidationError): + WorkstreamDefaultCheckerResult.model_validate(changed) + + +def test_behavior_identity_binds_executable_secret_patterns_and_flags() -> None: + changed = { + **WORKSTREAM_DEFAULT_BEHAVIOR_MANIFEST, + "secret_signatures": [ + *WORKSTREAM_DEFAULT_BEHAVIOR_MANIFEST["secret_signatures"][:-1], + { + **WORKSTREAM_DEFAULT_BEHAVIOR_MANIFEST["secret_signatures"][-1], + "pattern": "changed", + }, + ], + } + + assert canonical_json_hash(changed) != WORKSTREAM_DEFAULT_BEHAVIOR_SHA256 + + +def test_failure_shape_is_bound_to_its_exact_default() -> None: + with pytest.raises(ValidationError): + WorkstreamDefaultCheck( + check_id="received_identity_exact", + status="failed", + failure_code="high_confidence_secret_detected", + finding_count=1, + ) + with pytest.raises(ValidationError): + WorkstreamDefaultCheck( + check_id="high_confidence_secrets_absent", + status="passed", + failure_code="high_confidence_secret_detected", + finding_count=1, + ) + with pytest.raises(ValidationError): + WorkstreamDefaultCheck( + check_id="high_confidence_secrets_absent", + status="passed", + failure_code="high_confidence_secret_detected", + finding_count=0, + ) + + +def test_warning_order_is_closed_and_does_not_change_eligibility() -> None: + result = _passing_result() + values = result.model_dump(mode="python") + values["warnings"] = [ + {"code": "submission_attestation_missing"}, + {"code": "submission_summary_missing"}, + ] + + with pytest.raises(ValidationError): + WorkstreamDefaultCheckerResult.model_validate(values) + + values = result.model_dump(mode="python") + values["eligible"] = False + with pytest.raises(ValidationError): + WorkstreamDefaultCheckerResult.model_validate(values) diff --git a/backend/tests/checkers/default/test_execution.py b/backend/tests/checkers/default/test_execution.py new file mode 100644 index 000000000..e4fb3215e --- /dev/null +++ b/backend/tests/checkers/default/test_execution.py @@ -0,0 +1,357 @@ +"""Behavior proof for the unselected Workstream default checker.""" + +from __future__ import annotations + +import hashlib +from dataclasses import replace +from io import BytesIO +from pathlib import Path +from typing import Any, cast +import zipfile + +import pytest + +from app.core.identifiers import new_record_id +from app.modules.artifacts.sources import ArtifactCommitment +from app.modules.artifacts.submission_archive import ( + SubmissionArchiveFailureCode, + SubmissionArchiveInspector, + SubmissionArchiveLimits, +) +from app.modules.artifacts.submission_manifest import build_submission_manifest +from app.modules.checkers.api.default_checker import ( + WorkstreamDefaultCheckerInput, + WorkstreamDefaultPredecessor, +) +from app.modules.checkers.api.materialization import SubmissionMaterialEntry +from app.modules.checkers.default_checker import ( + WorkstreamDefaultCheckerUnavailable, + evaluate_rejected_archive, + evaluate_verified_archive, +) +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView + + +RULES_A = "sha256:" + "a" * 64 +RULES_B = "sha256:" + "b" * 64 + + +def _archive(files: dict[str, bytes], *, comment: bytes = b"") -> bytes: + target = BytesIO() + with zipfile.ZipFile(target, "w") as output: + output.comment = comment + for path, content in sorted(files.items()): + entry = zipfile.ZipInfo(path, date_time=(1980, 1, 1, 0, 0, 0)) + output.writestr(entry, content) + return target.getvalue() + + +def _facts(value: bytes): + inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) + inspection = inspector.inspect(BytesIO(value)) + manifest = build_submission_manifest(inspection) + commitment = ArtifactCommitment( + "sha256:" + hashlib.sha256(value).hexdigest(), len(value), "application/zip" + ) + return inspector, inspection, manifest, commitment + + +def _input( + value: bytes, + *, + predecessor: WorkstreamDefaultPredecessor | None = None, + current_predecessor: WorkstreamDefaultPredecessor | None = None, + current_rules_sha256: str = RULES_A, + predecessor_rules_sha256: str | None = None, + summary: str = "Implemented the requested behavior.", + attestation: str = "I confirm the submitted material is authorized.", +) -> WorkstreamDefaultCheckerInput: + _, inspection, manifest, commitment = _facts(value) + return WorkstreamDefaultCheckerInput( + archive_sha256=commitment.sha256, + archive_byte_count=commitment.byte_count, + archive_media_type=commitment.media_type, + semantic_manifest_sha256=manifest.sha256, + entries=tuple( + SubmissionMaterialEntry( + item.normalized_path, + item.entry_type.value, + item.byte_count, + item.sha256, + item.executable, + ) + for item in inspection.entries + ), + predecessor=predecessor, + predecessor_exists=predecessor is not None, + current_predecessor=current_predecessor, + current_rules_sha256=current_rules_sha256, + predecessor_rules_sha256=predecessor_rules_sha256, + packet=SubmissionPacketView( + summary=summary, contributor_attestation=attestation + ), + ) + + +def _evaluate(value: bytes, request: WorkstreamDefaultCheckerInput, workspace: Path): + inspector, inspection, _, _ = _facts(value) + workspace.mkdir(mode=0o700) + return inspector.project_and_run( + BytesIO(value), + workspace, + expected=inspection, + callback=lambda tree: evaluate_verified_archive(request, tree), + ) + + +def test_safe_first_submission_passes_exact_four_defaults(tmp_path: Path) -> None: + archive = _archive({"src/main.txt": b"verified work"}) + result = _evaluate(archive, _input(archive), tmp_path / "work") + + assert result.eligible is True + assert [item.status for item in result.checks] == ["passed"] * 4 + assert result.warnings == () + + +@pytest.mark.parametrize( + "failure", + [ + SubmissionArchiveFailureCode.MALFORMED, + SubmissionArchiveFailureCode.LIMIT_EXCEEDED, + SubmissionArchiveFailureCode.UNSAFE_ENTRY, + SubmissionArchiveFailureCode.COLLISION, + SubmissionArchiveFailureCode.ENCRYPTED, + ], +) +def test_contributor_archive_failures_return_one_bounded_finding( + failure: SubmissionArchiveFailureCode, +) -> None: + result = evaluate_rejected_archive( + failure.value, + packet=SubmissionPacketView(summary="", contributor_attestation=""), + ) + + assert result.eligible is False + assert result.checks[0].failure_code == failure.value + assert [item.status for item in result.checks] == [ + "failed", + "not_run", + "not_run", + "not_run", + ] + assert [item.code for item in result.warnings] == [ + "submission_summary_missing", + "submission_attestation_missing", + ] + + +@pytest.mark.parametrize( + "failure", + [ + SubmissionArchiveFailureCode.INTEGRITY_FAILURE, + SubmissionArchiveFailureCode.TIMEOUT, + ], +) +def test_archive_infrastructure_failures_never_become_work_findings( + failure: SubmissionArchiveFailureCode, +) -> None: + with pytest.raises(WorkstreamDefaultCheckerUnavailable, match=failure.value): + evaluate_rejected_archive( + failure.value, + packet=SubmissionPacketView( + summary="summary", contributor_attestation="attestation" + ), + ) + + +@pytest.mark.parametrize( + "secret", + [ + b"-----BEGIN OPENSSH PRIVATE KEY-----\nencoded", + b"AKIAIOSFODNN7EXAMPLE", + b"ghp_" + b"a" * 36, + b"github_pat_" + b"a" * 82, + b"glpat-" + b"a" * 20, + b"AIza" + b"a" * 35, + b"xoxb-" + b"a" * 20, + b"sk_live_" + b"a" * 16, + ], +) +def test_high_confidence_secret_signatures_fail_without_retaining_values( + secret: bytes, tmp_path: Path +) -> None: + archive = _archive({"src/config.txt": b"prefix " + secret + b" suffix"}) + result = _evaluate(archive, _input(archive), tmp_path / "work") + + assert result.eligible is False + assert result.checks[2].failure_code == "high_confidence_secret_detected" + assert result.checks[2].finding_count == 1 + assert secret.decode() not in result.model_dump_json() + + +def test_secret_near_misses_and_missing_packet_text_are_warning_only( + tmp_path: Path, +) -> None: + archive = _archive( + { + "notes.txt": ( + b"BEGIN PUBLIC KEY; AKIA-too-short; ghp_short; " + b"sk_test_1234567890123456" + ) + } + ) + result = _evaluate( + archive, + _input(archive, summary=" \n", attestation=""), + tmp_path / "work", + ) + + assert result.eligible is True + assert [item.status for item in result.checks] == ["passed"] * 4 + assert [item.code for item in result.warnings] == [ + "submission_summary_missing", + "submission_attestation_missing", + ] + + +def test_exact_and_semantic_replay_fail_until_task_rules_change(tmp_path: Path) -> None: + first = _archive({"src/main.txt": b"same work"}) + _, _, manifest, commitment = _facts(first) + predecessor = WorkstreamDefaultPredecessor( + new_record_id(), 1, commitment.sha256, manifest.sha256 + ) + + exact = _evaluate( + first, + _input( + first, + predecessor=predecessor, + current_predecessor=predecessor, + predecessor_rules_sha256=RULES_A, + ), + tmp_path / "exact", + ) + repacked = _archive({"src/main.txt": b"same work"}, comment=b"repacked") + semantic = _evaluate( + repacked, + _input( + repacked, + predecessor=predecessor, + current_predecessor=predecessor, + predecessor_rules_sha256=RULES_A, + ), + tmp_path / "semantic", + ) + rules_changed = _evaluate( + first, + _input( + first, + predecessor=predecessor, + current_predecessor=predecessor, + current_rules_sha256=RULES_B, + predecessor_rules_sha256=RULES_A, + ), + tmp_path / "rules-changed", + ) + with pytest.raises( + WorkstreamDefaultCheckerUnavailable, match="submission_predecessor_stale" + ): + _evaluate( + first, + _input( + first, + predecessor=predecessor, + current_predecessor=WorkstreamDefaultPredecessor( + new_record_id(), + 2, + commitment.sha256, + manifest.sha256, + ), + current_rules_sha256=RULES_B, + predecessor_rules_sha256=RULES_A, + ), + tmp_path / "rules-changed-stale", + ) + + assert exact.checks[3].failure_code == "submission_archive_unchanged" + assert semantic.checks[3].failure_code == "submission_manifest_unchanged" + assert rules_changed.checks[3].status == "passed" + assert rules_changed.eligible is True + + +def test_stale_predecessor_and_cross_archive_manifest_are_infrastructure( + tmp_path: Path, +) -> None: + archive = _archive({"src/main.txt": b"candidate"}) + _, _, manifest, _ = _facts(archive) + predecessor = WorkstreamDefaultPredecessor( + new_record_id(), 1, "sha256:" + "1" * 64, "sha256:" + "2" * 64 + ) + stale = WorkstreamDefaultPredecessor( + new_record_id(), 2, "sha256:" + "3" * 64, "sha256:" + "4" * 64 + ) + with pytest.raises( + WorkstreamDefaultCheckerUnavailable, match="submission_predecessor_stale" + ): + _evaluate( + archive, + _input( + archive, + predecessor=predecessor, + current_predecessor=stale, + predecessor_rules_sha256=RULES_A, + ), + tmp_path / "stale", + ) + + other = _archive({"src/other.txt": b"other"}) + cross = _input(archive) + assert cross.semantic_manifest_sha256 == manifest.sha256 + with pytest.raises( + WorkstreamDefaultCheckerUnavailable, match="submission_material_identity_invalid" + ): + _evaluate(other, cross, tmp_path / "cross") + + +def test_detached_manifest_and_callback_bytes_cannot_be_substituted( + tmp_path: Path, +) -> None: + archive = _archive({"src/main.txt": b"candidate"}) + request = _input(archive) + with pytest.raises( + WorkstreamDefaultCheckerUnavailable, + match="submission_manifest_identity_invalid", + ): + _evaluate( + archive, + replace(request, semantic_manifest_sha256="sha256:" + "0" * 64), + tmp_path / "manifest", + ) + + other = _archive({"src/main.txt": b"substitute"}) + with pytest.raises( + WorkstreamDefaultCheckerUnavailable, + match="submission_material_identity_invalid", + ): + _evaluate(other, request, tmp_path / "bytes") + + +def test_predecessor_currentness_rejects_duck_typed_equal_values() -> None: + class AlwaysEqual: + def __eq__(self, other: object) -> bool: + return True + + archive = _archive({"src/main.txt": b"candidate"}) + request = _input(archive) + foreign = cast(Any, AlwaysEqual()) + + with pytest.raises(ValueError, match="default checker input is invalid"): + replace(request, current_predecessor=foreign) + with pytest.raises(ValueError, match="default checker input is invalid"): + replace( + request, + predecessor=foreign, + predecessor_exists=True, + current_predecessor=foreign, + predecessor_rules_sha256=RULES_A, + ) diff --git a/backend/tests/checkers/execution/completion_fixture.py b/backend/tests/checkers/execution/completion_fixture.py index 0f9b4b197..3d6d227d7 100644 --- a/backend/tests/checkers/execution/completion_fixture.py +++ b/backend/tests/checkers/execution/completion_fixture.py @@ -1,4 +1,4 @@ -"""Stored completion fixtures; controlled handlers prove classification, not input quality.""" +"""Stored selected completion fixtures with no routing or acceptance shortcut.""" import json from contextlib import asynccontextmanager @@ -7,71 +7,32 @@ from sqlalchemy import select, text from app.modules.checkers.api.execution import EvaluationCompletion -from app.modules.checkers.api.post_submit import PostSubmitCounter, PostSubmitMemberResult from app.modules.checkers.models import CheckerRun -from app.modules.checkers.post_submit_contracts import make_post_submit_result -from app.modules.checkers.runner import CheckerOutcome, CheckerRegistry, FunctionChecker, default_checker_registry from app.modules.outbox.models import OutboxEvent +from tests.checkers.external.support import post_registry_spec from tests.post_submit_materialization_helpers import material_fixture -from .support import live_executor, reserve -from .test_concurrency import final_facts - - -FAILURE_CHECKERS = { - "needs_revision": "check_submission_packet", - "task_setup_blocked": "check_policy_context_present", -} - - -def controlled_registry(recommendation): - """Keep canonical definitions; change only one selected handler's declared outcome.""" - canonical = default_checker_registry() - if recommendation == "allow_review": - return canonical - definition = canonical.resolve(FAILURE_CHECKERS[recommendation]).definition - - async def declared_failure(context): - return CheckerOutcome( - checker_name=definition.capability_id, status=definition.failure_status, - severity=definition.failure_severity, message="Controlled declared failure.", - ) - - registry = CheckerRegistry() - for item in canonical.definitions(): - checker = ( - FunctionChecker(item.capability_id, declared_failure) - if item.capability_id == definition.capability_id - else canonical.resolve(item.capability_id).checker - ) - registry.register(checker, definition=item) - return registry +from .support import ControlledSelectedExecution, live_executor, reserve @asynccontextmanager -async def completed_evidence(tmp_path, database_url, recommendation="needs_revision", *, counters=False, **options): - """Use real material, AUTH, finalization and publication; never insert a terminal row.""" - async with material_fixture(tmp_path, database_url, **options) as h: +async def completed_evidence(tmp_path, database_url, recommendation="needs_revision", **options): + """Use real selected material, AUTH, finalization and event publication.""" + if recommendation not in {"allow_review", "needs_revision"}: + raise ValueError("selected completion recommendation is unsupported") + failed = recommendation == "needs_revision" + async with material_fixture( + tmp_path, + database_url, + post_submit_registry_spec=post_registry_spec() if failed else None, + **options, + ) as h: await reserve(h) - executor = live_executor(h, registry=controlled_registry(recommendation)) - if counters: - lease, replay = await executor._claim(h.request) - assert replay is None - facts = await final_facts(h, lease) - definition = h.request.catalogue.definition(FAILURE_CHECKERS[recommendation], "v0.1") - member = PostSubmitMemberResult( - checker_id=definition.capability_id, implementation_version=definition.implementation_version, - status=definition.failure_status, code=definition.failure_code, - failure_category=definition.failure_category, severity=definition.failure_severity, - counters=(PostSubmitCounter(key="missing_count", value=2),), - ) - fields = facts.result.model_dump(exclude={"result_digest"}) - fields["member_results"] = tuple( - member if m.checker_id == member.checker_id else m for m in facts.result.member_results - ) - controlled = make_post_submit_result(**fields) - h.result = await executor.finalize(facts.model_copy(update={"result": controlled})) - else: - h.result = await executor.evaluate_post_submission(h.request) + h.result = await live_executor( + h, + selected_execution=ControlledSelectedExecution( + verdict="failed" if failed else "passed" + ), + ).evaluate_post_submission(h.request) async with h.factory() as session: h.run = await session.get(CheckerRun, str(h.result.attempt_id)) assert h.run.routing_recommendation == recommendation diff --git a/backend/tests/checkers/execution/historical_execution.py b/backend/tests/checkers/execution/historical_execution.py index dfd92d083..8e0a1c731 100644 --- a/backend/tests/checkers/execution/historical_execution.py +++ b/backend/tests/checkers/execution/historical_execution.py @@ -1,382 +1,347 @@ -"""Revision-shaped checker history for migration tests only, never current AUTH proof. +"""Literal predecessor rows for migration tests, never an execution implementation. -All predecessor database guards remain enabled. No current input receipt is -fabricated: that evidence did not exist in these schema revisions. +Callers supply archived members, counts and routing. This file only encodes those +facts and inserts them with the predecessor database constraints enabled. It does +not invoke current CHECKERS, AUTH, outbox or TASK owners on an archived schema. """ +import json from contextlib import asynccontextmanager +from copy import deepcopy from datetime import timedelta from uuid import UUID -from pydantic import BaseModel -from sqlalchemy import insert, text +from sqlalchemy import text -from app.adapters.outbox import outbox_append +from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id -from app.modules.checkers.api.execution import ( - EvaluationReservation, - ExecutionLease, - EvaluationCompletion, - VerifiedMaterialFacts, - COMPLETION_EVENT, -) -from app.modules.checkers.api.post_submit import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationResult, - PostSubmitCurrentResultReference, -) -from app.modules.checkers.api.post_submit_catalogue import canonical_post_submit_bytes -from app.modules.checkers.execution_repository import request_text -from app.modules.checkers.execution_results import classify_result -from app.modules.checkers.models import CheckerRun, CheckerResult -from app.modules.checkers.post_submit_contracts import make_post_submit_result -from app.modules.outbox.api import OutboxAppendInput -from app.modules.tasks.models import AuditEvent -from tests.checkers.post_submit.test_result_contract import result as value_result from tests.historical_submission_fixtures import historical_material_fixture -class HistoricalTerminalFacts(BaseModel): - request: PostSubmissionEvaluationRequest - lease: ExecutionLease - result: PostSubmissionEvaluationResult - material: VerifiedMaterialFacts | None +def _json_uuid(value): + if isinstance(value, UUID): + return str(value) + raise TypeError(f"Non-JSON archived fact: {type(value).__name__}") -async def historical_revision(session): - revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) - assert 8 <= int(revision[:4]) <= 28, "historical fixture used outside its predecessor schemas" - assert not await session.scalar( - text("""SELECT EXISTS(SELECT 1 FROM information_schema.columns - WHERE table_schema='public' AND table_name='checker_runs' - AND column_name='input_materialization_evidence_id')""") +def _canonical_text(value, *, exclude=frozenset()): + return json.dumps( + {key: item for key, item in value.items() if key not in exclude}, + default=_json_uuid, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, ) + + +def _json_facts(value): + return json.loads(_canonical_text(value)) + + +async def archived_schema_revision(session): + revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) + assert 8 <= int(revision[:4]) <= 28, "archive seeder used outside predecessor schemas" + assert not await session.scalar(text("""SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='checker_runs' + AND column_name='input_materialization_evidence_id')""")) return int(revision[:4]) -async def historical_reserve(h): - r, c = h.request, h.request.expected_context +async def seed_archived_checker_request(h): + request = h.request + context = request["expected_context"] + run_id, result_id = new_record_id(), new_record_id() async with h.factory() as session, session.begin(): - await historical_revision(session) - previous = await session.scalar( - text( - "SELECT current_run_id FROM public.checker_submission_fences WHERE submission_id=:id" - ), - {"id": r.submission_id}, - ) - run_id, result_id = new_record_id(), new_record_id() - await session.execute( - insert(CheckerRun.__table__).values( - id=str(run_id), - project_id=str(r.project_id), - task_id=str(r.task_id), - submission_id=str(r.submission_id), - submission_version=r.submission_version, - evaluation_request_id=str(r.evaluation_request_id), - phase="post_submission", - evaluation_generation=r.evaluation_generation, - request_json=request_text(r), - request_digest=r.request_sha256, - result_id=str(result_id), - trigger_source="coordinated_evaluation", - status="queued", - outcome_source="none", - routing_recommendation="not_evaluated", - worker_lease_generation=0, - supersedes_checker_run_id=str(previous) if previous else None, - locked_guide_version=c.guide_version, - locked_post_submit_checker_policy_id=str(c.post_policy_id), - locked_post_submit_checker_policy_version=c.post_policy_version, - locked_post_submit_checker_policy_hash=c.post_policy_hash, - locked_review_policy_id=str(c.review_policy_id), - locked_review_policy_generation=c.review_generation, - locked_review_policy_hash=c.review_hash, - locked_revision_policy_id=str(c.revision_policy_id), - locked_revision_policy_generation=c.revision_generation, - locked_revision_policy_hash=c.revision_hash, - ) - ) - await session.execute( - text( - "INSERT INTO public.checker_submission_fences(submission_id,current_run_id) VALUES (:submission,:run)" - if previous is None else - "UPDATE public.checker_submission_fences SET current_run_id=:run WHERE submission_id=:submission" - ), - {"submission": r.submission_id, "run": run_id}, - ) - return EvaluationReservation( - request_id=r.evaluation_request_id, - request_digest=r.request_sha256, - attempt_id=run_id, - result_id=result_id, - evaluation_generation=r.evaluation_generation, - ) + await archived_schema_revision(session) + previous = await session.scalar(text( + "SELECT current_run_id FROM public.checker_submission_fences WHERE submission_id=:id" + ), {"id": UUID(str(request["submission_id"]))}) + await session.execute(text("""INSERT INTO public.checker_runs ( + id, project_id, task_id, submission_id, submission_version, + evaluation_request_id, phase, evaluation_generation, request_json, + request_digest, result_id, trigger_source, status, outcome_source, + routing_recommendation, worker_lease_generation, supersedes_checker_run_id, + locked_guide_version, locked_post_submit_checker_policy_id, + locked_post_submit_checker_policy_version, locked_post_submit_checker_policy_hash, + locked_review_policy_id, locked_review_policy_generation, locked_review_policy_hash, + locked_revision_policy_id, locked_revision_policy_generation, locked_revision_policy_hash, + passed_count, warning_count, failed_count, blocking_count + ) VALUES ( + :id, :project, :task, :submission, :version, + :request_id, 'post_submission', :generation, :request_json, + :digest, :result_id, 'coordinated_evaluation', 'queued', 'none', + 'not_evaluated', 0, :previous, + :guide, :post_id, :post_version, :post_hash, + :review_id, :review_generation, :review_hash, + :revision_id, :revision_generation, :revision_hash, 0, 0, 0, 0 + )"""), { + "id": run_id, + "project": UUID(str(request["project_id"])), + "task": UUID(str(request["task_id"])), + "submission": UUID(str(request["submission_id"])), + "version": request["submission_version"], + "request_id": UUID(str(request["evaluation_request_id"])), + "generation": request["evaluation_generation"], + "request_json": _canonical_text(request, exclude={"request_sha256"}), + "digest": request["request_sha256"], + "result_id": result_id, + "previous": previous, + "guide": context["guide_version"], + "post_id": UUID(str(context["post_policy_id"])), + "post_version": context["post_policy_version"], + "post_hash": context["post_policy_hash"], + "review_id": UUID(str(context["review_policy_id"])), + "review_generation": context["review_generation"], + "review_hash": context["review_hash"], + "revision_id": UUID(str(context["revision_policy_id"])), + "revision_generation": context["revision_generation"], + "revision_hash": context["revision_hash"], + }) + await session.execute(text( + "INSERT INTO public.checker_submission_fences(submission_id,current_run_id) " + "VALUES (:submission,:run)" if previous is None else + "UPDATE public.checker_submission_fences SET current_run_id=:run " + "WHERE submission_id=:submission" + ), {"submission": UUID(str(request["submission_id"])), "run": run_id}) + return { + "request_id": UUID(str(request["evaluation_request_id"])), + "request_digest": request["request_sha256"], + "attempt_id": run_id, + "result_id": result_id, + "evaluation_generation": request["evaluation_generation"], + } -async def _historical_receipt(session, run_id, phase, receipt_id): - """Persist the predecessor's exact SQL-validated event shape, not a live permission.""" - row = ( - ( - await session.execute( - text("""SELECT r.project_id,r.evaluation_request_id, +async def _seed_archived_receipt(session, run_id, phase, receipt_id): + """Store the then-installed SQL receipt shape as archived migration data.""" + row = (await session.execute(text("""SELECT r.project_id,r.evaluation_request_id, public.checker_post_submit_authority_digest(r,:phase) AS digest, - (SELECT id FROM public.actor_profiles WHERE service_identity='workstream.checker.post_submit') AS actor - FROM public.checker_runs r WHERE r.id=:id"""), - {"id": run_id, "phase": phase}, - ) - ) - .mappings() - .one() - ) + (SELECT id FROM public.actor_profiles + WHERE service_identity='workstream.checker.post_submit') AS actor + FROM public.checker_runs r WHERE r.id=:id"""), + {"id": run_id, "phase": phase})).mappings().one() assert row["actor"] is not None - now = await session.scalar(text("SELECT clock_timestamp()")) - await session.execute( - insert(AuditEvent.__table__).values( - id=str(receipt_id), - entity_type="authorization_decision", - entity_id=str(receipt_id), - event_type="SensitiveAuthorizationAllowed", - actor_id=str(row["actor"]), - actor_roles=[], - claim_snapshot={}, - auth_source="local_authority", - is_dev_auth=False, - event_payload={}, - event_domain="authority", - event_version=1, - occurred_at=now, - reason="authorization_evaluation", - actor_ref_kind="actor_profile", - request_id=str(row["evaluation_request_id"]), - correlation_id=str(row["evaluation_request_id"]), - action_id="checker.post_submit." + phase, - permission_id="checker.post_submit." + phase, - project_id=str(row["project_id"]), - resource_type="checker_run", - resource_id=str(run_id), - target_ref_kind="project", - target_ref_id=str(row["project_id"]), - after_facts={"allowed": True, "resource_context_digest": row["digest"]}, - ) - ) + await session.execute(text("""INSERT INTO public.audit_events ( + id, entity_type, entity_id, event_type, actor_id, actor_roles, claim_snapshot, + auth_source, is_dev_auth, event_payload, event_domain, event_version, occurred_at, + reason, actor_ref_kind, request_id, correlation_id, action_id, permission_id, + project_id, resource_type, resource_id, target_ref_kind, target_ref_id, after_facts + ) VALUES ( + :id, 'authorization_decision', :id, 'SensitiveAuthorizationAllowed', :actor, + CAST('[]' AS json), CAST('{}' AS json), 'local_authority', false, CAST('{}' AS json), + 'authority', 1, clock_timestamp(), 'authorization_evaluation', 'actor_profile', + :request, :request, :action, :action, :project, 'checker_run', :resource, + 'project', :target, CAST(:facts AS json) + )"""), { + "id": receipt_id, + "actor": str(row["actor"]), + "request": UUID(str(row["evaluation_request_id"])), + "action": "checker.post_submit." + phase, + "project": UUID(str(row["project_id"])), + "resource": str(run_id), + "target": str(row["project_id"]), + "facts": _canonical_text({"allowed": True, "resource_context_digest": row["digest"]}), + }) -async def historical_lease(h): +async def seed_archived_running_checker(h): async with h.factory() as session, session.begin(): - revision = await historical_revision(session) - r = ( - ( - await session.execute( - text("""SELECT r.* FROM public.checker_runs r - JOIN public.checker_submission_fences f ON f.current_run_id=r.id WHERE f.submission_id=:id"""), - {"id": h.request.submission_id}, - ) - ) - .mappings() - .one() - ) + revision = await archived_schema_revision(session) + row = (await session.execute(text("""SELECT r.* FROM public.checker_runs r + JOIN public.checker_submission_fences f ON f.current_run_id=r.id + WHERE f.submission_id=:id"""), + {"id": UUID(str(h.request["submission_id"]))})).mappings().one() now = await session.scalar(text("SELECT clock_timestamp()")) - lease = ExecutionLease( - reservation=EvaluationReservation( - request_id=UUID(str(r["evaluation_request_id"])), - request_digest=r["request_digest"], - attempt_id=UUID(str(r["id"])), - result_id=UUID(str(r["result_id"])), - evaluation_generation=r["evaluation_generation"], - ), - lease_id=new_record_id(), - lease_generation=1, - expires_at=now + timedelta(seconds=300), - ) - receipt_id = new_record_id() - await session.execute( - text("""UPDATE public.checker_runs SET status='running',started_at=:now, - worker_lease_id=:lease,worker_lease_generation=1,worker_lease_expires_at=:expires, - execute_evidence_id=:receipt WHERE id=:id"""), - { - "id": r["id"], - "now": now, - "lease": lease.lease_id, - "expires": lease.expires_at, - "receipt": receipt_id, + lease = { + "reservation": { + "request_id": UUID(str(row["evaluation_request_id"])), + "request_digest": row["request_digest"], + "attempt_id": UUID(str(row["id"])), + "result_id": UUID(str(row["result_id"])), + "evaluation_generation": row["evaluation_generation"], }, - ) + "lease_id": new_record_id(), + "lease_generation": 1, + "expires_at": now + timedelta(seconds=300), + "execute_evidence_id": new_record_id(), + } + await session.execute(text("""UPDATE public.checker_runs SET status='running', + started_at=:now, worker_lease_id=:lease, worker_lease_generation=1, + worker_lease_expires_at=:expires, execute_evidence_id=:receipt WHERE id=:id"""), { + "id": row["id"], "now": now, "lease": lease["lease_id"], + "expires": lease["expires_at"], "receipt": lease["execute_evidence_id"], + }) if revision >= 10: - await _historical_receipt(session, r["id"], "execute", receipt_id) + await _seed_archived_receipt(session, row["id"], "execute", lease["execute_evidence_id"]) return lease -def historical_terminal_facts(h, lease, outcome="completed"): - result = value_result( - h.request, attempt_id=lease.reservation.attempt_id, result_id=lease.reservation.result_id - ) - if outcome == "infrastructure_failed": - result = make_post_submit_result( - **( - result.model_dump(exclude={"result_digest"}) - | { - "outcome": outcome, - "member_results": (), - "infrastructure_failure_code": "implementation_unavailable", - } - ) - ) - return HistoricalTerminalFacts( - request=h.request, - lease=lease, - result=result, - material=VerifiedMaterialFacts( - submission_id=h.request.submission_id, - submission_version=h.request.submission_version, - admission_id=h.created.admission_id, - binding_id=h.request.binding_id, - content_id=h.request.content_id, - replica_id=h.replica_id, - content_sha256=h.request.content_sha256, - byte_count=h.request.byte_count, - semantic_manifest_sha256=h.manifest.sha256, - ), - ) - - -async def historical_write_terminal(session, facts, material): - revision = await historical_revision(session) - request, result = facts.request, facts.result - run_id = result.attempt_id - execute_id = await session.scalar( - text("SELECT execute_evidence_id FROM public.checker_runs WHERE id=:id"), {"id": run_id} - ) +def archived_terminal_rows(h, lease, *, outcome, members, counts, + routing_recommendation, failure_code, material): + """Encode supplied archived terminal facts without classifying any member.""" + request = _json_facts(h.request) + reservation = lease["reservation"] + serialized_members = [] + for member in members: + assert member.get("schema_version", "post_submit_structural_result") == ( + "post_submit_structural_result" + ), "archived member protocol tag differs from predecessor SQL" + serialized_members.append({ + "schema_version": "post_submit_structural_result", + **{key: deepcopy(member[key]) for key in ( + "checker_id", "definition_version", "implementation_version", "status", + "code", "failure_category", "severity", "counters", + )}, + }) + result = _json_facts({ + "schema_version": "post_submit_evaluation_result", + "request_id": request["evaluation_request_id"], + "request_digest": request["request_sha256"], + "attempt_id": reservation["attempt_id"], + "evaluation_generation": request["evaluation_generation"], + "result_id": reservation["result_id"], + "outcome": outcome, + "member_results": serialized_members, + "infrastructure_failure_code": failure_code, + }) + result["result_digest"] = canonical_json_hash(result) final_id = new_record_id() - classification = classify_result(request, result) - for order, member in enumerate(result.member_results): - await session.execute( - insert(CheckerResult.__table__).values( - id=str(new_record_id()), - checker_run_id=str(run_id), - task_id=str(request.task_id), - submission_id=str(request.submission_id), - member_order=order, - checker_name=member.checker_id, - definition_version=member.definition_version, - implementation_version=member.implementation_version, - status=member.status, - severity=member.severity, - code=member.code, - failure_category=member.failure_category, - counters=[v.model_dump(mode="json") for v in member.counters], - ) - ) - event_id = None - if result.outcome == "completed": - reference = PostSubmitCurrentResultReference( - **result.model_dump( - include=set(PostSubmitCurrentResultReference.model_fields) - {"schema_version"} - ) - ) - event = EvaluationCompletion( - project_id=request.project_id, - task_id=request.task_id, - submission_id=request.submission_id, - reference=reference, - routing_recommendation=classification.routing, - output_binding_ids=(), - execute_evidence_id=UUID(str(execute_id)), - finalize_evidence_id=final_id, - ) - appended = await outbox_append(session).append( - OutboxAppendInput( - event_type=COMPLETION_EVENT, - event_version=1, - aggregate_type="checker_run", - aggregate_id=run_id, - project_id=request.project_id, - correlation_id=str(request.evaluation_request_id), - idempotency_key="checker-completed:" + str(run_id), - payload=event.model_dump(mode="json"), - ) - ) - event_id = appended.event_id - import json + outbox = None + if outcome == "completed": + payload = { + "project_id": request["project_id"], + "task_id": request["task_id"], + "submission_id": request["submission_id"], + "reference": {"schema_version": "post_submit_current_result_reference", **{ + key: result[key] for key in ( + "request_id", "request_digest", "attempt_id", "evaluation_generation", + "result_id", "result_digest", + ) + }}, + "routing_recommendation": routing_recommendation, + "output_binding_ids": [], + "execute_evidence_id": str(lease["execute_evidence_id"]), + "finalize_evidence_id": str(final_id), + } + outbox = { + "event_id": new_record_id(), + "event_type": "PostSubmissionEvaluationCompleted", + "event_version": 1, + "aggregate_type": "checker_run", + "aggregate_id": reservation["attempt_id"], + "project_id": UUID(str(request["project_id"])), + "correlation_id": request["evaluation_request_id"], + "idempotency_key": "checker-completed:" + str(reservation["attempt_id"]), + "payload": payload, + "payload_digest": canonical_json_hash(payload), + } + member_rows = [{ + "id": new_record_id(), + "checker_run_id": reservation["attempt_id"], + "task_id": UUID(str(request["task_id"])), + "submission_id": UUID(str(request["submission_id"])), + "member_order": order, + "checker_name": member["checker_id"], + **{key: deepcopy(member[key]) for key in ( + "definition_version", "implementation_version", "status", "severity", "code", + "failure_category", "counters", + )}, + } for order, member in enumerate(result["member_results"])] + return { + "request": request, + "lease": deepcopy(lease), + "result": result, + "material": _json_facts(material) if material is not None else None, + "member_rows": member_rows, + "outbox": outbox, + "run": { + "id": reservation["attempt_id"], + "status": outcome, + "result_json": _canonical_text(result, exclude={"result_digest"}), + "result_digest": result["result_digest"], + "material_custody": _json_facts(material) if material is not None else None, + "finalize_evidence_id": final_id, + "routing_recommendation": routing_recommendation, + "failure_code": failure_code, + "passed_count": counts["passed"], + "warning_count": counts["warning"], + "failed_count": counts["failed"], + "blocking_count": counts["blocking"], + "completion_event_id": outbox["event_id"] if outbox is not None else None, + }, + } - await session.execute( - text("""UPDATE public.checker_runs SET status=:status, - result_json=:body,result_digest=:digest,material_custody=CAST(:material AS json), - finalize_evidence_id=:final,completed_at=clock_timestamp(),outcome_source='auto_checker', - routing_recommendation=:routing,failure_code=:failure,passed_count=:passed, - warning_count=:warning,failed_count=:failed,blocking_count=:blocking,completion_event_id=:event WHERE id=:id"""), - dict( - id=run_id, - status=result.outcome, - body=canonical_post_submit_bytes(result, exclude={"result_digest"}).decode(), - digest=result.result_digest, - material=json.dumps(material) if material is not None else None, - final=final_id, - routing=classification.routing, - failure=result.infrastructure_failure_code, - passed=classification.passed, - warning=classification.warning, - failed=classification.failed, - blocking=classification.blocking, - event=event_id, - ), - ) + +async def seed_archived_terminal(session, rows): + """Insert supplied rows inside the caller's transaction; constraints decide validity.""" + revision = await archived_schema_revision(session) + for member in rows["member_rows"]: + await session.execute(text("""INSERT INTO public.checker_results ( + id, checker_run_id, task_id, submission_id, member_order, checker_name, + definition_version, implementation_version, status, severity, code, + failure_category, counters + ) VALUES ( + :id, :checker_run_id, :task_id, :submission_id, :member_order, :checker_name, + :definition_version, :implementation_version, :status, :severity, :code, + :failure_category, CAST(:counters AS json) + )"""), member | {"counters": json.dumps(member["counters"], allow_nan=False)}) + event = rows["outbox"] + if event is not None: + await session.execute(text("""INSERT INTO public.outbox_events ( + event_id, event_type, event_version, aggregate_type, aggregate_id, project_id, + correlation_id, idempotency_key, payload, payload_digest + ) VALUES ( + :event_id, :event_type, :event_version, :aggregate_type, :aggregate_id, :project_id, + :correlation_id, :idempotency_key, CAST(:payload AS jsonb), :payload_digest + )"""), event | {"payload": _canonical_text(event["payload"])}) + run = rows["run"] + await session.execute(text("""UPDATE public.checker_runs SET status=:status, + result_json=:result_json, result_digest=:result_digest, + material_custody=CAST(:material_custody AS json), + finalize_evidence_id=:finalize_evidence_id, completed_at=clock_timestamp(), + outcome_source='auto_checker', routing_recommendation=:routing_recommendation, + failure_code=:failure_code, passed_count=:passed_count, warning_count=:warning_count, + failed_count=:failed_count, blocking_count=:blocking_count, + completion_event_id=:completion_event_id WHERE id=:id"""), run | { + "material_custody": _canonical_text(run["material_custody"]) + if run["material_custody"] is not None else None, + }) if revision >= 10: - await _historical_receipt(session, run_id, "finalize", final_id) + await _seed_archived_receipt(session, run["id"], "finalize", run["finalize_evidence_id"]) @asynccontextmanager -async def historical_completed_source(tmp_path, database_url, **options): +async def archived_completed_checker_source(tmp_path, database_url, **options): async with historical_material_fixture(tmp_path, database_url, **options) as h: - await historical_reserve(h) - lease = await historical_lease(h) - facts = historical_terminal_facts(h, lease) + await seed_archived_checker_request(h) + lease = await seed_archived_running_checker(h) + rows = archived_terminal_rows( + h, lease, outcome="completed", members=h.archived_members, counts=h.archived_counts, + routing_recommendation="allow_review", failure_code=None, material=h.archived_material, + ) async with h.factory() as session, session.begin(): - await historical_write_terminal(session, facts, facts.material.model_dump(mode="json")) - h.result, h.material = facts.result, facts.material.model_dump(mode="json") + await seed_archived_terminal(session, rows) + h.result, h.material, h.completion_event = rows["result"], rows["material"], rows["outbox"] async with h.factory() as session: - r = ( - ( - await session.execute( - text("""SELECT r.*,s.task_assignment_id,s.contributor_id, - s.contribution_policy_version_id,p.human_review_required FROM public.checker_runs r - JOIN public.submissions s ON s.id=r.submission_id - JOIN public.review_policies p ON p.id=s.locked_review_policy_id WHERE r.id=:id"""), - {"id": facts.result.attempt_id}, - ) - ) - .mappings() - .one() - ) - names = ( - "project_id", - "task_id", - "submission_id", - "submission_version", - "contributor_id", - "contribution_policy_version_id", - "evaluation_request_id", - "request_digest", - "evaluation_generation", - "result_id", - "result_digest", - "completion_event_id", - "execute_evidence_id", - "finalize_evidence_id", - "human_review_required", - ) - h.source = {name: r[name] for name in names} | { + row = (await session.execute(text("""SELECT r.*,s.task_assignment_id,s.contributor_id, + s.contribution_policy_version_id,p.human_review_required FROM public.checker_runs r + JOIN public.submissions s ON s.id=r.submission_id + JOIN public.review_policies p ON p.id=s.locked_review_policy_id WHERE r.id=:id"""), + {"id": rows["run"]["id"]})).mappings().one() + h.source = {name: row[name] for name in ( + "project_id", "task_id", "submission_id", "submission_version", "contributor_id", + "contribution_policy_version_id", "evaluation_request_id", "request_digest", + "evaluation_generation", "result_id", "result_digest", "completion_event_id", + "execute_evidence_id", "finalize_evidence_id", "human_review_required", + )} | { "id": new_record_id(), - "checker_run_id": r["id"], - "assignment_id": r["task_assignment_id"], - **{ - name: h.material[name] - for name in ( - "replica_id", - "content_sha256", - "byte_count", - "semantic_manifest_sha256", - ) - }, + "checker_run_id": row["id"], + "assignment_id": row["task_assignment_id"], + **{name: h.material[name] for name in ( + "replica_id", "content_sha256", "byte_count", "semantic_manifest_sha256", + )}, } for name, value in h.source.items(): if name.endswith("_id") or name == "id": diff --git a/backend/tests/checkers/execution/material_storage_helpers.py b/backend/tests/checkers/execution/material_storage_helpers.py index a626e31c8..f82e4156c 100644 --- a/backend/tests/checkers/execution/material_storage_helpers.py +++ b/backend/tests/checkers/execution/material_storage_helpers.py @@ -10,8 +10,8 @@ from app.adapters.outbox import outbox_append from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import COMPLETION_EVENT, EvaluationCompletion -from app.modules.checkers.api.post_submit import PostSubmitCurrentResultReference -from app.modules.checkers.api.post_submit_catalogue import canonical_post_submit_bytes +from app.modules.checkers.api.selected_post_submit import SelectedPostSubmitCurrentResultReference +from app.modules.checkers.api.canonical_values import canonical_checker_bytes from app.modules.checkers.execution_results import classify_result from app.modules.checkers.models import CheckerResult, CheckerRun from app.modules.outbox.api import OutboxAppendInput @@ -40,19 +40,24 @@ async def stage_terminal(session, facts, material, evidence_id): request, result = facts.request, facts.result run = await session.get(CheckerRun, str(result.attempt_id)) classification = classify_result(request, result) - for order, member in enumerate(result.member_results): + for member in result.execution.external_members if result.execution else (): await session.execute(insert(CheckerResult).values( id=str(new_record_id()), checker_run_id=run.id, task_id=run.task_id, - submission_id=run.submission_id, member_order=order, checker_name=member.checker_id, - definition_version=member.definition_version, implementation_version=member.implementation_version, - status=member.status, severity=member.severity, code=member.code, - failure_category=member.failure_category, - counters=[item.model_dump(mode="json") for item in member.counters], + submission_id=run.submission_id, member_order=member.order, + checker_name=member.requirement_id, + definition_version=member.result.schema_version, + implementation_version=member.registry_entry_digest, + status=member.result.verdict, + severity="info" if member.result.verdict == "passed" else "error", + code=("passed" if member.result.verdict == "passed" else + member.result.findings[0].code if member.result.findings else "invalid_output"), + failure_category="none" if member.result.verdict == "passed" else "submission_structure", + counters=[], )) event_id = None if result.outcome == "completed": - reference = PostSubmitCurrentResultReference(**result.model_dump( - include=set(PostSubmitCurrentResultReference.model_fields) - {"schema_version"}, + reference = SelectedPostSubmitCurrentResultReference(**result.model_dump( + include=set(SelectedPostSubmitCurrentResultReference.model_fields) - {"schema_version"}, )) event = EvaluationCompletion( project_id=request.project_id, task_id=request.task_id, submission_id=request.submission_id, @@ -72,7 +77,7 @@ async def stage_terminal(session, facts, material, evidence_id): .where(CheckerRun.id == run.id) .values( status=result.outcome, - result_json=canonical_post_submit_bytes(result, exclude={"result_digest"}).decode(), + result_json=canonical_checker_bytes(result, exclude={"result_digest"}).decode(), result_digest=result.result_digest, material_custody=material, finalize_evidence_id=str(evidence_id), diff --git a/backend/tests/checkers/execution/storage_fixture.py b/backend/tests/checkers/execution/storage_fixture.py index efd6a3f0f..21a24a2c8 100644 --- a/backend/tests/checkers/execution/storage_fixture.py +++ b/backend/tests/checkers/execution/storage_fixture.py @@ -1,18 +1,11 @@ """Closed history with canonical stored ART lineage and real fixed-service phase authority.""" -from app.modules.checkers.api.post_submit import make_post_submit_request +from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_request from uuid import UUID from app.modules.actors.api import ServiceIdentity from app.modules.artifacts.models import SubmissionBundleAdmission -from app.modules.checkers.api.execution import ExecuteFacts, FinalizeFacts, VerifiedMaterialFacts -from app.modules.checkers.api.post_submit import PostSubmitMemberResult from app.adapters.checkers import evaluation_coordinator -from app.modules.checkers.post_submit_contracts import ( - make_post_submit_result, -) -from app.modules.tasks.models import Submission -from tests.checkers.post_submit.test_result_contract import result as value_result from tests.checkers.execution.support import live_executor, provision_post_submit_service from types import SimpleNamespace @@ -30,77 +23,83 @@ async def storage_request(session, submission_id, *, generation=1): return stored.request body = stored.request.model_dump(exclude={"request_sha256"}) body.update(evaluation_generation=generation, evaluation_request_id=new_record_id()) - return make_post_submit_request(**body) + return make_selected_post_submit_request(**body) -async def seed_storage_run(factory, submission_id, *, failures=(), state="completed", generation=1): +class _UnavailableArchiveStore: + """Report a provider outage through the current ART byte-read boundary.""" + + def __init__(self, store): + self.identity = store.identity + + async def open(self, _ref, byte_range=None): + from app.interfaces.artifacts import ArtifactStoreUnavailableError + + raise ArtifactStoreUnavailableError() + yield # The ArtifactStore.open port is an async byte iterator. + + +async def seed_storage_run(factory, submission_id, *, verdict="passed", state="completed", generation=1): + """Execute the locked current policy with controlled image/provider outcomes.""" + from pathlib import Path + from tempfile import TemporaryDirectory + + from app.adapters.artifacts import post_submission_materialization + from app.modules.artifacts.models import ArtifactStorageNamespace + from app.modules.artifacts.preparation import ArtifactPreparationService, ArtifactScratchManager + from app.modules.artifacts.service import ArtifactStorageNamespaceSpec + from app.modules.artifacts.submission_archive import SubmissionArchiveInspector, SubmissionArchiveLimits + from tests.checkers.execution.support import ControlledSelectedExecution + from tests.retained_material_fixtures import retained_archive_store + from tests.test_checker_materialization import _limits + + assert verdict in {"passed", "failed"} + assert state in {"queued", "running", "completed", "infrastructure_failed"} + assert verdict == "passed" or state == "completed" async with factory() as session, session.begin(): request = await storage_request(session, submission_id, generation=generation) + assert verdict != "failed" or request.policy.entries, "failed verdict requires a locked registered image" receipt = await evaluation_coordinator(session).reserve_current_evaluation(request) if state == "queued": return str(receipt.attempt_id) await provision_post_submit_service(factory) - executor = live_executor(SimpleNamespace(factory=factory, service=None)) - lease, replay = await executor._claim(request) - assert replay is None if state == "running": + _, replay = await live_executor(SimpleNamespace(factory=factory, service=None))._claim(request) + assert replay is None return str(receipt.attempt_id) - assert state in {"completed", "infrastructure_failed"} - members = [] - for member in value_result(request).member_results: - if member.checker_id in failures: - definition = request.catalogue.definition(member.checker_id, member.definition_version) - member = PostSubmitMemberResult( - checker_id=member.checker_id, - definition_version=member.definition_version, - implementation_version=member.implementation_version, - status=definition.failure_status, - severity=definition.failure_severity, - code=definition.failure_code, - failure_category=definition.failure_category, - ) - members.append(member) - assert set(failures).issubset({member.checker_id for member in members}) - result = make_post_submit_result( - request_id=request.evaluation_request_id, - request_digest=request.request_sha256, - attempt_id=receipt.attempt_id, - result_id=receipt.result_id, - evaluation_generation=generation, - outcome=state, - member_results=tuple(members) if state == "completed" else (), - infrastructure_failure_code=None if state == "completed" else "deadline_exceeded", - ) - async with factory() as session: - submission = await session.get(Submission, str(submission_id)) - admission = await session.get(SubmissionBundleAdmission, submission.submission_bundle_admission_id) - admission_id = UUID(admission.id) - material = VerifiedMaterialFacts( - submission_id=request.submission_id, - submission_version=request.submission_version, - admission_id=admission_id, - binding_id=request.binding_id, - content_id=request.content_id, - replica_id=UUID(admission.verified_replica_id), - content_sha256=request.content_sha256, - byte_count=request.byte_count, - semantic_manifest_sha256=admission.semantic_manifest_sha256, - ) await provision_post_submit_service(factory, ServiceIdentity.ARTIFACT_MATERIALIZER) - input_receipt = await authorize_stored_material( - factory, ExecuteFacts(request=request, lease=lease), material - ) - await executor.finalize( - FinalizeFacts( - request=request, - lease=lease, - result=result, - material=material, - input_materialization_evidence_id=input_receipt, - output_binding_ids=(), + async with factory() as session: + persisted = await session.get(ArtifactStorageNamespace, "primary") + namespace = ArtifactStorageNamespaceSpec( + persisted.backend, persisted.adapter, persisted.provider_profile, + persisted.namespace_descriptor, persisted.namespace_fingerprint, ) - ) - return str(receipt.attempt_id) + store = retained_archive_store(factory, namespace) + if state == "infrastructure_failed": + store = _UnavailableArchiveStore(store) + with TemporaryDirectory(prefix="retained-post-material-") as directory: + manager = ArtifactScratchManager( + root=Path(directory), + limits=_limits(reservation_ttl_seconds=300, total_deadline_seconds=180), + ) + try: + materialization = post_submission_materialization( + sessions=factory, store=store, namespace=namespace, + preparation=ArtifactPreparationService(manager), + inspector=SubmissionArchiveInspector(SubmissionArchiveLimits()), + ) + executor = live_executor( + SimpleNamespace(factory=factory, service=materialization), + selected_execution=ControlledSelectedExecution(verdict=verdict), + ) + result = await executor.evaluate_post_submission(request) + assert result.outcome == state + if state == "completed": + assert result.execution.required_external_count == len(request.policy.entries) + assert all(member.result.verdict == verdict for member in result.execution.external_members) + return str(receipt.attempt_id) + finally: + manager.close() async def authorize_stored_material(factory, execution, material): @@ -149,7 +148,7 @@ async def authorize_stored_material(factory, execution, material): MaterializationFacts( execution=execution, material=material, - evidence_id=UUID(a.pre_submit_evidence_set_id), + evidence_id=UUID(a.selected_pre_submit_run_id), verification_receipt_id=UUID(v.id), verification_job_id=UUID(v.verification_job_id), verification_generation=v.execution_generation, diff --git a/backend/tests/checkers/execution/support.py b/backend/tests/checkers/execution/support.py index dc8f5d8d4..ea5b7b445 100644 --- a/backend/tests/checkers/execution/support.py +++ b/backend/tests/checkers/execution/support.py @@ -8,6 +8,117 @@ from app.modules.actors.models import ActorProfile, ActorIdentityLink +class ControlledSelectedExecution: + """Return one request-bound selected result without replacing owner logic.""" + + def __init__(self, *, verdict="passed"): + self.verdict = verdict + self.requests = [] + + async def execute(self, request, grant): + from app.interfaces.external_checker_execution import ( + ExternalCheckerExecutionResponse, + ExternalCheckerIsolationReceipt, + ExternalCheckerMaterialGrant, + ) + from app.modules.checkers.api.external import ( + make_external_checker_execution_result, + ) + + assert type(grant) is ExternalCheckerMaterialGrant + self.requests.append(request) + findings = () if self.verdict == "passed" else ({ + "code": "post_evidence_failed", + "level": "error", + "message": "Post evidence failed.", + },) + result = make_external_checker_execution_result( + request_digest=request.request_digest, + registry_entry_id=request.registry.registry_entry_id, + registry_entry_digest=request.registry.entry_digest, + phase="post_submit", + outcome="completed", + verdict=self.verdict, + findings=findings, + infrastructure_failure_code=None, + ) + sha = "sha256:" + "a" * 64 + return ExternalCheckerExecutionResponse( + result=result, + isolation=ExternalCheckerIsolationReceipt( + repository="registry.example/workstream/checker", + platform_manifest_digest=request.registry.image_digest, + platform_manifest_media_type=( + "application/vnd.oci.image.manifest.v1+json" + ), + platform_manifest_byte_count=1024, + operating_system="linux", + architecture="amd64", + config_image_id=sha, + runtime="runsc", + isolation_mode="gvisor", + sandbox_uid=65532, + sandbox_gid=65532, + ), + ) + + +def selected_request(*, project_id=None): + """Supply a closed empty selected policy for pure contract boundary tests.""" + from app.core.hashing import canonical_json_hash + from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy + from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmitLockedContext, make_selected_post_submit_request, + ) + + sha = "sha256:" + "a" * 64 + policy = LockedExternalPostSubmitPolicy( + entries=(), policy_sha256=canonical_json_hash({ + "schema_version": "external_post_submit_policy.v1", "entries": [], + }), + ) + context = { + name: (new_record_id() if name.endswith("_id") else + 1 if name.endswith("generation") else + "v1" if "version" in name else sha) + for name in SelectedPostSubmitLockedContext.model_fields + } + context["post_policy_hash"] = policy.policy_sha256 + return make_selected_post_submit_request( + project_id=project_id or new_record_id(), + expected_context=SelectedPostSubmitLockedContext(**context), policy=policy, + evaluation_request_id=new_record_id(), evaluation_generation=1, + task_id=new_record_id(), assignment_id=new_record_id(), + submission_id=new_record_id(), submission_version=1, + content_id=new_record_id(), binding_id=new_record_id(), + content_sha256=sha, byte_count=42, + ) + + +def selected_result(source, **changes): + """Supply closed empty-policy evidence for owner and material boundary tests.""" + from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmissionEvaluationRequest, + SelectedPostSubmitExecutionFacts, + make_selected_post_submit_result, + ) + + source = SelectedPostSubmissionEvaluationRequest.model_validate(source) + assert source.policy.entries == (), "external members require actual image execution" + fields = dict( + request_id=source.evaluation_request_id, + request_digest=source.request_sha256, + attempt_id=new_record_id(), + evaluation_generation=source.evaluation_generation, + result_id=new_record_id(), + outcome="completed", + execution=SelectedPostSubmitExecutionFacts(source.policy.policy_sha256, 0, ()), + infrastructure_failure_code=None, + ) + fields.update(changes) + return make_selected_post_submit_result(**fields) + + class ForbiddenPostSubmission: async def evaluate_post_submission(self, request): pytest.fail("pre-submit-only test entered post-submit execution") @@ -20,35 +131,49 @@ def forbidden_post_submission(): async def provision_post_submit_service(factory, identity=ServiceIdentity.CHECKER_POST_SUBMIT): """Create fixture principal once without resetting an existing lifecycle decision.""" async with factory() as session, session.begin(): - existing = await session.scalar(select(ActorProfile).where( - ActorProfile.service_identity == identity.value, - )) + existing = await session.scalar( + select(ActorProfile).where( + ActorProfile.service_identity == identity.value, + ) + ) if existing is not None: return existing.id actor_id, link_id = str(new_record_id()), str(new_record_id()) - session.add(ActorProfile( - id=actor_id, actor_kind="service", status="active", - provisioning_method="manual_service_provisioning", - service_identity=identity.value, created_by="checker-test", - )) - session.add(ActorIdentityLink( - id=link_id, actor_profile_id=actor_id, issuer="flow-test", subject=actor_id, - subject_kind="service", status="active", linked_by="checker-test", - )) + session.add( + ActorProfile( + id=actor_id, + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=identity.value, + created_by="checker-test", + ) + ) + session.add( + ActorIdentityLink( + id=link_id, + actor_profile_id=actor_id, + issuer="flow-test", + subject=actor_id, + subject_kind="service", + status="active", + linked_by="checker-test", + ) + ) return actor_id -def live_executor(h, *, registry=None, outbox=None): +def live_executor(h, *, outbox=None, selected_execution=None): from app.adapters.auth import post_submit_execution_authority from app.adapters.outbox import outbox_append from app.modules.checkers.execution import PostSubmissionExecutor - from app.modules.checkers.runner import default_checker_registry return PostSubmissionExecutor( - sessions=h.factory, materialization=h.service, + sessions=h.factory, + materialization=h.service, execute_authority=post_submit_execution_authority, finalize_authority=post_submit_execution_authority, - registry=registry if registry is not None else default_checker_registry(), + selected_execution=selected_execution or ControlledSelectedExecution(), outbox=outbox if outbox is not None else outbox_append, ) @@ -75,17 +200,24 @@ async def material_execution(h): async def service_link_state(factory, identity, *, active): """Change real persisted service lifecycle in an independent transaction.""" from sqlalchemy import text + async with factory() as session, session.begin(): if active: - await session.execute(text( - "update actor_identity_links set status='active', revoked_by=null, revoked_at=null, " - "revoked_reason=null, reactivated_by='test', reactivated_at=clock_timestamp(), " - "reactivation_reason='test' where actor_profile_id=" - "(select id from actor_profiles where service_identity=:identity)" - ), {"identity": identity.value}) + await session.execute( + text( + "update actor_identity_links set status='active', revoked_by=null, revoked_at=null, " + "revoked_reason=null, reactivated_by='test', reactivated_at=clock_timestamp(), " + "reactivation_reason='test' where actor_profile_id=" + "(select id from actor_profiles where service_identity=:identity)" + ), + {"identity": identity.value}, + ) else: - await session.execute(text( - "update actor_identity_links set status='revoked', revoked_by='test', " - "revoked_at=clock_timestamp(), revoked_reason='test' where actor_profile_id=" - "(select id from actor_profiles where service_identity=:identity)" - ), {"identity": identity.value}) + await session.execute( + text( + "update actor_identity_links set status='revoked', revoked_by='test', " + "revoked_at=clock_timestamp(), revoked_reason='test' where actor_profile_id=" + "(select id from actor_profiles where service_identity=:identity)" + ), + {"identity": identity.value}, + ) diff --git a/backend/tests/checkers/execution/test_checker_retry.py b/backend/tests/checkers/execution/test_checker_retry.py index e7572452c..90c2339e6 100644 --- a/backend/tests/checkers/execution/test_checker_retry.py +++ b/backend/tests/checkers/execution/test_checker_retry.py @@ -893,7 +893,7 @@ async def test_running_source_reaches_valid_task_guard_then_refuses_terminal_ret await _assert_nonterminal_source_reaches_guard(h, monkeypatch, "running") -@pytest.mark.parametrize("recommendation", ["allow_review", "task_setup_blocked"]) +@pytest.mark.parametrize("recommendation", ["allow_review", "needs_revision"]) async def test_completed_source_reaches_valid_task_guard_then_refuses_terminal_retry( tmp_path, isolated_database_env, monkeypatch, recommendation, ): @@ -910,12 +910,18 @@ async def test_expired_unfinished_transport_reclaims_same_run_without_retry_rece async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) executor = live_executor(h) - monkeypatch.setattr(execution, "LEASE_SECONDS", 1) - first, replay = await executor._claim(h.request) + with monkeypatch.context() as patch: + patch.setattr(executor, "_execution_budget_seconds", lambda _request: 1) + patch.setattr(execution, "SELECTED_POST_FINALIZATION_MARGIN_MS", 9_000) + first, replay = await executor._claim(h.request) assert replay is None async with h.factory() as session: - await session.execute(text("select pg_sleep(1.05)")) - monkeypatch.setattr(execution, "LEASE_SECONDS", 300) + await session.execute(text( + "SELECT pg_sleep(GREATEST(EXTRACT(EPOCH FROM " + "CAST(:expiry AS timestamptz)-clock_timestamp()),0)+0.05)" + ), {"expiry": first.expires_at}) + assert await session.scalar(text("SELECT clock_timestamp()>=:expiry"), + {"expiry": first.expires_at}) second, replay = await executor._claim(h.request) assert replay is None assert second.reservation == first.reservation diff --git a/backend/tests/checkers/execution/test_checker_retry_history_replay.py b/backend/tests/checkers/execution/test_checker_retry_history_replay.py index ed246db3c..709d41a12 100644 --- a/backend/tests/checkers/execution/test_checker_retry_history_replay.py +++ b/backend/tests/checkers/execution/test_checker_retry_history_replay.py @@ -9,7 +9,9 @@ from app.modules.checkers.api.execution import CheckerExecutionUnavailable from app.modules.outbox.api import HandlerOutcome from tests.authorization.post_submit_routing.support import provision_router -from tests.checkers.execution.completion_fixture import controlled_registry +from tests.checkers.external.support import post_registry_spec +from tests.checkers.execution.support import ControlledSelectedExecution +from app.modules.checkers.models import CheckerRun from tests.checkers.execution.test_checker_retry import _command, _failed_source, _operator_for from tests.tasks.evaluation_delivery.support import outcome from tests.tasks.post_submit_routing.outcome_support import invoked_completion @@ -24,7 +26,17 @@ async def test_retry_replays_after_real_later_submission_without_reopening_old_source( tmp_path, isolated_database_env, monkeypatch, ): - async with _failed_source(tmp_path, isolated_database_env, monkeypatch) as (h, delivery): + selected_execution = ControlledSelectedExecution(verdict="failed") + monkeypatch.setattr( + "app.adapters.checkers.configured_external_checker_execution", + lambda _settings: selected_execution, + ) + async with _failed_source( + tmp_path, + isolated_database_env, + monkeypatch, + post_submit_registry_spec=post_registry_spec(), + ) as (h, delivery): from app.adapters.artifacts.local import LocalStorageAdapter operator = await _operator_for(h) @@ -32,17 +44,18 @@ async def test_retry_replays_after_real_later_submission_without_reopening_old_s async with h.factory.begin() as session: created = await checker_retry_service(session, operator).retry(command) - # The replacement cause comes from the retry successor's real checker - # completion and governed route, using a controlled declared failure. - import app.modules.checkers.runner as runner - - registry = controlled_registry("needs_revision") - with monkeypatch.context() as patch: - patch.setattr(runner, "default_checker_registry", lambda: registry) - delivered = await delivery.delivery.deliver( - created.successor.event_id, h.request.project_id, "retry-remediation", - ) + # The replacement cause comes from the retry successor's exact selected + # policy completion and governed route. + delivered = await delivery.delivery.deliver( + created.successor.event_id, h.request.project_id, "retry-remediation", + ) assert outcome(delivered)["delivery_state"] == "acknowledged" + assert len(selected_execution.requests) == 1 + async with h.factory() as session: + successor = await session.get(CheckerRun, str(created.successor.run_id)) + assert successor.status == "completed" + assert successor.routing_recommendation == "needs_revision" + assert successor.completion_event_id is not None async with h.factory.begin() as session: accepted = await session.execute(text( "UPDATE public.task_assignments SET accepted_at=clock_timestamp() " diff --git a/backend/tests/checkers/execution/test_checker_retry_migration.py b/backend/tests/checkers/execution/test_checker_retry_migration.py index f578d4a92..0e6f76b4c 100644 --- a/backend/tests/checkers/execution/test_checker_retry_migration.py +++ b/backend/tests/checkers/execution/test_checker_retry_migration.py @@ -1,86 +1,25 @@ """0035 preserves retained checker custody and installs terminal retry closure.""" import asyncio -import io import json -import os -from pathlib import Path -import subprocess -import sys -import tarfile from uuid import UUID import asyncpg import pytest from alembic import command +from sqlalchemy import text from sqlalchemy.exc import IntegrityError -from app.adapters.checkers import evaluation_coordinator +from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.db import session as db_session -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationRequest -from tests.checkers.post_submit.support import change_request +from tests.historical_submission_fixtures import restore_archived_snapshot from tests.migration_fixtures import _config pytestmark = pytest.mark.postgres_schema_contract PREDECESSOR = "0034_checker_replacement" SUCCESSOR = "0035_checker_retry" -# Pin the last F3 writer: current F4 delivery reads the 0035 retry table. -HISTORICAL_WRITER = "926b4ef118db3708e50d1869db5ee78a79e5b5de" -HISTORICAL_SEED = """ -import asyncio -import json -import os -from pathlib import Path -import sys - -import pytest - -from app.adapters.artifacts import internal_workers -from app.adapters.artifacts.local import LocalStorageAdapter -from app.db import session as db_session -from app.interfaces.artifacts import ArtifactObjectMissingError -from tests.post_submit_materialization_helpers import material_fixture -from tests.tasks.evaluation_delivery.support import delivery_fixture, outcome, state -from tests.tasks.evaluation_delivery.test_worker_materialization import _worker_port - - -async def seed(): - database_url = os.environ["WS_MIGRATION_DATABASE_URL"] - tmp_path = Path(sys.argv[1]) - result_path = Path(sys.argv[2]) - - def missing(_store, _reference): - raise ArtifactObjectMissingError("controlled verified object loss") - - try: - with pytest.MonkeyPatch.context() as monkeypatch: - async with material_fixture(tmp_path, database_url, provider="local") as h: - port = _worker_port(h, monkeypatch) - try: - delivery = await delivery_fixture(h, materialization=port) - with monkeypatch.context() as patch: - patch.setattr(LocalStorageAdapter, "open", missing) - initial = await delivery.delivery.deliver( - h.created.evaluation_event_id, h.request.project_id, "retry-source", - ) - assert outcome(initial)["delivery_state"] == "acknowledged" - saved = await state(h) - assert saved["run"]["status"] == "infrastructure_failed" - assert json.loads(saved["run"]["result_json"])["infrastructure_failure_code"] == "material_unavailable" - result_path.write_text(json.dumps({ - "submission_id": str(h.created.submission_id), - "request": h.request.model_dump(mode="json"), - })) - finally: - internal_workers.shutdown_artifact_internal_runtime() - finally: - await db_session.dispose_engine() - - -asyncio.run(seed()) -""" RETAINED_TABLES = ( "submissions", "submission_dispatches", @@ -122,36 +61,6 @@ async def _reset_to_predecessor(database_url): await asyncio.to_thread(command.upgrade, _config(), PREDECESSOR) -def _historical_backend(tmp_path): - root = Path(__file__).resolve().parents[4] - archived = subprocess.run( - ["git", "archive", "--format=tar", HISTORICAL_WRITER, "backend"], - cwd=root, - capture_output=True, - check=True, - ) - source = tmp_path / "historical" - with tarfile.open(fileobj=io.BytesIO(archived.stdout), mode="r:") as archive: - archive.extractall(source, filter="data") - return source / "backend" - - -def _seed_with_historical_writer(tmp_path, database_url): - backend = _historical_backend(tmp_path) - result_path = tmp_path / "historical-seed.json" - subprocess.run( - [sys.executable, "-c", HISTORICAL_SEED, str(tmp_path), str(result_path)], - cwd=backend, - env={ - **os.environ, - "PYTHONPATH": os.pathsep.join((str(backend), str(backend / "tests"))), - "WS_MIGRATION_DATABASE_URL": database_url, - }, - check=True, - ) - return json.loads(result_path.read_text()) - - async def _retained_rows(connection): return { table: tuple( @@ -167,7 +76,7 @@ async def _retained_rows(connection): async def _source_custody(connection, submission_id): return await connection.fetchrow( """ - SELECT r.status, r.failure_code, r.result_digest, + SELECT r.id AS run_id, r.status, r.failure_code, r.result_digest, r.finalize_evidence_id IS NOT NULL AS finalized, fin.action_id='checker.post_submit.finalize' AS finalize_authority_valid, fin.event_type='SensitiveAuthorizationAllowed' AS finalize_allowed, @@ -193,7 +102,7 @@ async def _source_custody(connection, submission_id): async def _predecessor_evidence(connection, submission_id): - """Verify the actual old writer's complete source before comparing its upgrade.""" + """Verify the complete archived source before comparing its upgrade.""" assert await connection.fetchval("SELECT version_num FROM public.alembic_version") == PREDECESSOR assert await connection.fetchval( "SELECT to_regclass('public.checker_retry_receipts') IS NULL" @@ -227,14 +136,12 @@ async def test_retry_upgrade_preserves_evidence_and_installs_closure( ): with migration_lock(): await _reset_to_predecessor(isolated_database_env) - seed = await asyncio.to_thread( - _seed_with_historical_writer, tmp_path, isolated_database_env + seed = await restore_archived_snapshot( + isolated_database_env, "0034_checker_retry_source" ) submission_id = UUID(seed["submission_id"]) - request = PostSubmissionEvaluationRequest.model_validate_json( - json.dumps(seed["request"]) - ) - assert request.submission_id == submission_id + request = seed["request"] + assert UUID(request["submission_id"]) == submission_id connection = await _connection(isolated_database_env) try: before, source = await _predecessor_evidence(connection, submission_id) @@ -274,17 +181,39 @@ async def test_retry_upgrade_preserves_evidence_and_installs_closure( finally: await connection.close() - successor = change_request( - request, - evaluation_request_id=new_record_id(), - evaluation_generation=2, - ) + # This is a raw predecessor SQL invariant, not a current runtime request. + successor_request = {key: value for key, value in request.items() if key != "request_sha256"} + successor_request.update(evaluation_request_id=str(new_record_id()), evaluation_generation=2) + successor_id = new_record_id() + request_json = json.dumps(successor_request, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + successor_row = { + "id": str(successor_id), "evaluation_request_id": successor_request["evaluation_request_id"], + "evaluation_generation": 2, "request_json": request_json, + "request_digest": canonical_json_hash(successor_request), "result_id": str(new_record_id()), + "supersedes_checker_run_id": str(source["run_id"]), + "status": "queued", "outcome_source": "none", "routing_recommendation": "not_evaluated", + "worker_lease_id": None, "worker_lease_generation": 0, "worker_lease_expires_at": None, + "execute_evidence_id": None, "finalize_evidence_id": None, + "result_json": None, "result_digest": None, "material_custody": None, + "input_materialization_evidence_id": None, "completion_event_id": None, + "started_at": None, "completed_at": None, "failure_code": None, + "passed_count": 0, "warning_count": 0, "failed_count": 0, "blocking_count": 0, + } with pytest.raises( IntegrityError, match="terminal checker infrastructure retry requires Operator receipt", ): async with db_session.get_session_factory().begin() as session: - await evaluation_coordinator(session).reserve_current_evaluation(successor) + await session.execute(text(""" + INSERT INTO public.checker_runs + SELECT (jsonb_populate_record(NULL::public.checker_runs, + to_jsonb(r) || CAST(:changes AS jsonb))).* + FROM public.checker_runs r WHERE id=:source + """), {"changes": json.dumps(successor_row), "source": source["run_id"]}) + await session.execute(text(""" + UPDATE public.checker_submission_fences SET current_run_id=:successor + WHERE submission_id=:submission + """), {"successor": successor_id, "submission": submission_id}) connection = await _connection(isolated_database_env) try: assert await _retained_rows(connection) == before @@ -294,3 +223,103 @@ async def test_retry_upgrade_preserves_evidence_and_installs_closure( ) finally: await connection.close() + +async def test_current_schema_refuses_new_archived_pre_submit_lineage( + isolated_database_env, + migration_lock, +): + """Retain archived rows while refusing every new old-lineage insertion.""" + with migration_lock(): + await _reset_to_predecessor(isolated_database_env) + await restore_archived_snapshot( + isolated_database_env, "0034_checker_retry_source" + ) + await db_session.dispose_engine() + await asyncio.to_thread(command.upgrade, _config(), "head") + connection = await _connection(isolated_database_env) + try: + before = await _retained_rows(connection) + intent_id = await connection.fetchval(""" + SELECT id FROM public.submission_bundle_durable_intents + WHERE pre_submit_evidence_set_id IS NOT NULL + """) + admission_id = await connection.fetchval(""" + SELECT id FROM public.submission_bundle_admissions + WHERE pre_submit_evidence_set_id IS NOT NULL + """) + attempt_id = await connection.fetchval( + "SELECT id FROM public.pre_submit_execution_attempts" + ) + evidence_id = await connection.fetchval( + "SELECT id FROM public.pre_submit_evidence_sets" + ) + result_id = await connection.fetchval( + "SELECT id FROM public.pre_submit_evidence_results ORDER BY result_order LIMIT 1" + ) + assert all((intent_id, admission_id, attempt_id, evidence_id, result_id)) + with pytest.raises( + asyncpg.CheckViolationError, + match="submission bundle durable intent requires selected PRE lineage", + ): + async with connection.transaction(): + await connection.execute(""" + INSERT INTO public.submission_bundle_durable_intents + SELECT (jsonb_populate_record( + NULL::public.submission_bundle_durable_intents, + to_jsonb(value) || jsonb_build_object('id',$1::uuid) + )).* FROM public.submission_bundle_durable_intents value + WHERE value.id=$2::uuid + """, new_record_id(), intent_id) + with pytest.raises( + asyncpg.CheckViolationError, + match="submission bundle admission requires selected PRE lineage", + ): + async with connection.transaction(): + await connection.execute(""" + INSERT INTO public.submission_bundle_admissions + SELECT (jsonb_populate_record( + NULL::public.submission_bundle_admissions, + to_jsonb(value) || jsonb_build_object('id',$1::uuid) + )).* FROM public.submission_bundle_admissions value + WHERE value.id=$2::uuid + """, new_record_id(), admission_id) + for table, source_id, message in ( + ( + "pre_submit_execution_attempts", + attempt_id, + "retained pre-submit attempts are immutable", + ), + ( + "pre_submit_evidence_sets", + evidence_id, + "retained pre-submit evidence is immutable", + ), + ( + "pre_submit_evidence_results", + result_id, + "retained pre-submit evidence results are immutable", + ), + ): + with pytest.raises(asyncpg.PostgresError, match=message) as refused: + async with connection.transaction(): + await connection.execute(f""" + INSERT INTO public.{table} + SELECT (jsonb_populate_record( + NULL::public.{table}, + to_jsonb(value) || jsonb_build_object('id',$1::uuid) + )).* FROM public.{table} value WHERE value.id=$2::uuid + """, new_record_id(), source_id) + assert refused.value.sqlstate == "55000" + with pytest.raises( + asyncpg.PostgresError, + match="retained pre-submit attempts are immutable", + ) as refused: + async with connection.transaction(): + await connection.execute(""" + UPDATE public.pre_submit_execution_attempts + SET status=status WHERE id=$1::uuid + """, attempt_id) + assert refused.value.sqlstate == "55000" + assert await _retained_rows(connection) == before + finally: + await connection.close() diff --git a/backend/tests/checkers/execution/test_completion_contract.py b/backend/tests/checkers/execution/test_completion_contract.py index df26bc4d9..0f3ad86b9 100644 --- a/backend/tests/checkers/execution/test_completion_contract.py +++ b/backend/tests/checkers/execution/test_completion_contract.py @@ -5,16 +5,15 @@ from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import EvaluationCompletion, VerifiedEvaluationCompletion, VerifiedMaterialFacts -from app.modules.checkers.api.post_submit import PostSubmitCurrentResultReference -from tests.checkers.post_submit.support import request -from tests.checkers.post_submit.test_result_contract import result +from app.modules.checkers.api.selected_post_submit import SelectedPostSubmitCurrentResultReference +from tests.checkers.execution.support import selected_request, selected_result def test_verified_completion_rejects_mismatched_result(): - source = request() - completed = result(source) - reference = PostSubmitCurrentResultReference(**completed.model_dump( - include=set(PostSubmitCurrentResultReference.model_fields) - {"schema_version"}, + source = selected_request() + completed = selected_result(source) + reference = SelectedPostSubmitCurrentResultReference(**completed.model_dump( + include=set(SelectedPostSubmitCurrentResultReference.model_fields) - {"schema_version"}, )) fields = dict( completion=EvaluationCompletion( @@ -33,9 +32,9 @@ def test_verified_completion_rejects_mismatched_result(): ) value = VerifiedEvaluationCompletion(**fields) assert VerifiedEvaluationCompletion.model_validate_json(value.model_dump_json()) == value - other = result(source) + other = selected_result(source) assert other.result_digest != completed.result_digest - infrastructure = result(source, outcome="infrastructure_failed", member_results=(), + infrastructure = selected_result(source, outcome="infrastructure_failed", execution=None, infrastructure_failure_code="material_unavailable") for invalid in (other, infrastructure): with pytest.raises(ValidationError, match="current reference result mismatch"): diff --git a/backend/tests/checkers/execution/test_completion_evidence.py b/backend/tests/checkers/execution/test_completion_evidence.py index 2d30ae4ab..1c754de3c 100644 --- a/backend/tests/checkers/execution/test_completion_evidence.py +++ b/backend/tests/checkers/execution/test_completion_evidence.py @@ -8,7 +8,7 @@ from app.adapters.checkers import evaluation_coordinator from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import CheckerExecutionUnavailable, VerifiedMaterialFacts -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationResult +from app.modules.checkers.api.evaluation_contract import validate_stored_execution_result from app.modules.checkers.models import CheckerSubmissionFence from tests.checkers.post_submit.support import change_request from .completion_fixture import completed_evidence, evidence_snapshot @@ -21,14 +21,14 @@ async def verify(session, h, *, completion=None, event_id=None): ) -@pytest.mark.parametrize("recommendation", ["allow_review", "needs_revision", "task_setup_blocked"]) +@pytest.mark.parametrize("recommendation", ["allow_review", "needs_revision"]) async def test_current_completion_returns_exact_stored_result(tmp_path, isolated_database_env, recommendation): async with completed_evidence(tmp_path, isolated_database_env, recommendation) as h: async with h.factory() as session: await session.begin() before = await evidence_snapshot(session) actual = await verify(session, h) - assert actual.result == h.result == PostSubmissionEvaluationResult.model_validate_json( + assert actual.result == h.result == validate_stored_execution_result( json.dumps(json.loads(h.run.result_json) | {"result_digest": h.run.result_digest}) ) assert actual.completion == h.completion @@ -44,26 +44,13 @@ async def test_current_completion_returns_exact_stored_result(tmp_path, isolated assert await evidence_snapshot(session) == before -async def test_current_completion_preserves_nonempty_stored_counters(tmp_path, isolated_database_env): - async with completed_evidence(tmp_path, isolated_database_env, counters=True) as h: - async with h.factory() as session, session.begin(): - actual = await verify(session, h) - stored = PostSubmissionEvaluationResult.model_validate_json( - json.dumps(json.loads(h.run.result_json) | {"result_digest": h.run.result_digest}) - ) - assert any(m.counters for m in stored.member_results), "fixture must discriminate dropped counters" - assert actual.result == stored == h.result - failed = next(m for m in actual.result.member_results if m.status == "failed") - assert [(c.key, c.value) for c in failed.counters] == [("missing_count", 2)] - - -@pytest.mark.parametrize("recommendation", ["allow_review", "needs_revision", "task_setup_blocked"]) +@pytest.mark.parametrize("recommendation", ["allow_review", "needs_revision"]) async def test_current_completion_rejects_wrong_derived_recommendation(tmp_path, isolated_database_env, recommendation): async with completed_evidence(tmp_path, isolated_database_env, recommendation) as h: async with h.factory() as session, session.begin(): await verify(session, h) before = await evidence_snapshot(session) - for wrong in {"allow_review", "needs_revision", "task_setup_blocked"} - {recommendation}: + for wrong in {"allow_review", "needs_revision"} - {recommendation}: altered = h.completion.model_copy(update={"routing_recommendation": wrong}) with pytest.raises(CheckerExecutionUnavailable, match="checker_current_completion_unavailable"): await verify(session, h, completion=altered) diff --git a/backend/tests/checkers/execution/test_concurrency.py b/backend/tests/checkers/execution/test_concurrency.py index 49af8bf6e..00e9d4d04 100644 --- a/backend/tests/checkers/execution/test_concurrency.py +++ b/backend/tests/checkers/execution/test_concurrency.py @@ -15,16 +15,34 @@ from app.adapters.checkers import evaluation_coordinator from app.modules.checkers.models import CheckerRun from tests.checkers.post_submit.support import change_request -from tests.checkers.post_submit.test_result_contract import result +from tests.checkers.execution.support import selected_result as result from tests.post_submit_materialization_helpers import material_fixture from .support import reserve, live_executor -async def final_facts(h, lease): +async def final_facts(h, lease, *, verdict="passed"): """A controlled closed result for lease races, not a byte-evaluation assertion.""" from tests.checkers.execution.storage_fixture import authorize_stored_material from app.modules.checkers.api.execution import ExecuteFacts + if h.request.policy.entries: + from dataclasses import asdict + from .support import ControlledSelectedExecution + + consumer = live_executor( + h, selected_execution=ControlledSelectedExecution(verdict=verdict), + )._consumer(h.request, lease) + materialized = await h.service.materialize(ExecuteFacts(request=h.request, lease=lease), consumer) + material = VerifiedMaterialFacts(**{ + key: value for key, value in asdict(materialized).items() + if key not in {"evaluation", "input_materialization_evidence_id"} + }) + return FinalizeFacts( + request=h.request, lease=lease, result=materialized.evaluation, + material=material, + input_materialization_evidence_id=materialized.input_materialization_evidence_id, + output_binding_ids=(), + ) material = VerifiedMaterialFacts( submission_id=h.request.submission_id, submission_version=h.request.submission_version, @@ -92,12 +110,18 @@ async def test_stale_worker_cannot_finalize_after_takeover( async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) executor = live_executor(h) - monkeypatch.setattr(execution, "LEASE_SECONDS", 1) - old, _ = await executor._claim(h.request) + with monkeypatch.context() as patch: + patch.setattr(executor, "_execution_budget_seconds", lambda _request: 1) + patch.setattr(execution, "SELECTED_POST_FINALIZATION_MARGIN_MS", 9_000) + old, _ = await executor._claim(h.request) old_facts = await final_facts(h, old) async with h.factory() as session: - await session.execute(text("select pg_sleep(1.05)")) - monkeypatch.setattr(execution, "LEASE_SECONDS", 300) + await session.execute(text( + "SELECT pg_sleep(GREATEST(EXTRACT(EPOCH FROM " + "CAST(:expiry AS timestamptz)-clock_timestamp()),0)+0.05)" + ), {"expiry": old.expires_at}) + assert await session.scalar(text("SELECT clock_timestamp()>=:expiry"), + {"expiry": old.expires_at}) current, _ = await executor._claim(h.request) assert ( current.reservation == old.reservation @@ -232,14 +256,14 @@ async def materialize(self, request, consumer): await probe("before_bytes") return await h.service.materialize(request, consumer) - original = execution._StructuralConsumer.evaluate + original = execution._SelectedExternalConsumer.evaluate async def evaluate(consumer, request, material): assert material.entries await probe("inside_consumer") return await original(consumer, request, material) - monkeypatch.setattr(execution._StructuralConsumer, "evaluate", evaluate) + monkeypatch.setattr(execution._SelectedExternalConsumer, "evaluate", evaluate) executor = live_executor(h) executor._execute_authority = TrackingAuthority executor._materialization = MaterializationProbe() @@ -256,7 +280,10 @@ async def test_member_insertion_waits_for_terminal_parent( from app.modules.checkers.execution_repository import ExecutionRepository from tests.auth_concurrency_support import wait_for_named_database_lock - async with material_fixture(tmp_path, isolated_database_env) as h: + from tests.checkers.external.support import post_registry_spec + + async with material_fixture(tmp_path, isolated_database_env, + post_submit_registry_spec=post_registry_spec()) as h: await reserve(h) executor = live_executor(h) lease, _ = await executor._claim(h.request) @@ -272,7 +299,7 @@ async def paused(repo, run, result): return await original(repo, run, result) monkeypatch.setattr(ExecutionRepository, "write_members", paused) - first = facts.result.member_results[0] + first = facts.result.execution.external_members[0] waiter = "checker-member-race-" + new_record_id().hex async def insert_member(): @@ -287,13 +314,13 @@ async def insert_member(): task_id=str(h.request.task_id), submission_id=str(h.request.submission_id), member_order=0, - checker_name=first.checker_id, - definition_version=first.definition_version, - implementation_version=first.implementation_version, - status=first.status, - code=first.code, - failure_category=first.failure_category, - severity=first.severity, + checker_name=first.requirement_id, + definition_version=first.result.schema_version, + implementation_version=first.registry_entry_digest, + status=first.result.verdict, + code="passed", + failure_category="none", + severity="info", counters=[], ) ) @@ -327,7 +354,7 @@ async def insert_member(): await asyncio.gather(*pending, return_exceptions=True) async with h.factory() as session: assert await session.scalar(select(func.count()).select_from(CheckerResult)) == len( - facts.result.member_results + facts.result.execution.external_members ) assert ( await session.get(CheckerRun, str(facts.result.attempt_id)) diff --git a/backend/tests/checkers/execution/test_coordination.py b/backend/tests/checkers/execution/test_coordination.py index a06835fd6..bec05fcf2 100644 --- a/backend/tests/checkers/execution/test_coordination.py +++ b/backend/tests/checkers/execution/test_coordination.py @@ -16,9 +16,7 @@ async def test_reservation_replay_rejects_changed_envelope(tmp_path, isolated_da async with material_fixture(tmp_path, isolated_database_env) as h: first = await reserve(h) assert await reserve(h) == first - changed = change_request(h.request, structural_input=h.request.structural_input.model_copy( - update={"summary": "Changed request with the same exact owner lineage."} - )) + changed = change_request(h.request, byte_count=h.request.byte_count + 1) with pytest.raises(CheckerRequestConflict, match="checker_request_conflict"): await reserve(h, changed) async with h.factory() as session: diff --git a/backend/tests/checkers/execution/test_execution.py b/backend/tests/checkers/execution/test_execution.py index ef8d8b6ea..b174d68a7 100644 --- a/backend/tests/checkers/execution/test_execution.py +++ b/backend/tests/checkers/execution/test_execution.py @@ -5,11 +5,11 @@ from app.modules.checkers.api.execution import CheckerExecutionUnavailable, COMPLETION_EVENT from app.adapters.checkers import evaluation_coordinator -from app.core.identifiers import new_record_id from app.modules.checkers.models import CheckerRun, CheckerResult from app.modules.outbox.models import OutboxEvent from tests.post_submit_materialization_helpers import material_fixture -from .support import live_executor, reserve, service_link_state +from .support import ControlledSelectedExecution, live_executor, reserve, service_link_state +from tests.checkers.external.support import post_registry_spec from app.modules.actors.api import ServiceIdentity @@ -75,8 +75,16 @@ async def observe_receipt(*args, **kwargs): run = await session.get(CheckerRun, str(result.attempt_id)) assert [str(receipt) for receipt in receipts] == [run.input_materialization_evidence_id] assert run.input_materialization_evidence_id is not None - assert len({run.input_materialization_evidence_id, run.execute_evidence_id, - run.finalize_evidence_id}) == 3 + assert ( + len( + { + run.input_materialization_evidence_id, + run.execute_evidence_id, + run.finalize_evidence_id, + } + ) + == 3 + ) assert run.material_custody == { "submission_id": str(h.created.submission_id), "submission_version": h.created.submission_version, @@ -97,9 +105,9 @@ async def observe_receipt(*args, **kwargs): ) ) assert tuple(row.checker_name for row in rows) == tuple( - m.checker_id for m in result.member_results + m.requirement_id for m in result.execution.external_members ) - assert len(rows) == len(h.request.policy.entries) + assert len(rows) == len(h.request.policy.entries) == 0 event = await session.scalar( select(OutboxEvent).where(OutboxEvent.event_type == COMPLETION_EVENT) ) @@ -110,30 +118,21 @@ async def observe_receipt(*args, **kwargs): assert list((h.scratch / "workspaces").iterdir()) == [] -async def test_infrastructure_failure_is_terminal(tmp_path, isolated_database_env, autoflush_clock): - from app.modules.checkers.runner import CheckerRegistry +async def test_unsupported_request_envelope_never_reaches_execution(monkeypatch): + """Raw obsolete or arbitrary envelopes cannot claim, materialize or publish.""" + import app.modules.checkers.execution as owner - async with material_fixture(tmp_path, isolated_database_env) as h: - await reserve(h) - executor = live_executor(h, registry=CheckerRegistry()) - failed = await executor.evaluate_post_submission(h.request) - assert failed.outcome == "infrastructure_failed" - assert failed.infrastructure_failure_code == "implementation_unavailable" - assert failed.member_results == () - opens = len(h.store.opens) - assert await executor.evaluate_post_submission(h.request) == failed - assert len(h.store.opens) == opens == 1 - async with h.factory() as session, session.begin(): - assert ( - await session.scalar( - select(func.count()) - .select_from(OutboxEvent) - .where(OutboxEvent.event_type == COMPLETION_EVENT) - ) - == 0 - ) - with pytest.raises(CheckerExecutionUnavailable, match="current_result"): - await evaluation_coordinator(session).read_current_result(h.request) + async def forbidden_claim(request): + pytest.fail("unsupported request reached execution lease or authority") + + executor = owner.PostSubmissionExecutor( + sessions=None, execute_authority=None, finalize_authority=None, + materialization=None, outbox=None, + ) + monkeypatch.setattr(executor, "_claim", forbidden_claim) + for schema in ("post_submit_evaluation_request", "arbitrary_request.v1"): + with pytest.raises(ValueError): + await executor.evaluate_post_submission({"schema_version": schema}) @pytest.mark.parametrize("substitution", ["prepared", "receipt"]) @@ -281,11 +280,19 @@ async def append(self, value): assert await self.session.scalar( select(func.count()).select_from(CheckerResult) ) == len(h.request.policy.entries) - assert await self.session.scalar(select(func.count()).select_from(AuditEvent)) == before[1] + 1 - assert await self.session.scalar(select(AuditEvent.action_id).where( - AuditEvent.action_id == "checker.post_submit.finalize", - AuditEvent.resource_id == str(lease.reservation.attempt_id), - )) == "checker.post_submit.finalize" + assert ( + await self.session.scalar(select(func.count()).select_from(AuditEvent)) + == before[1] + 1 + ) + assert ( + await self.session.scalar( + select(AuditEvent.action_id).where( + AuditEvent.action_id == "checker.post_submit.finalize", + AuditEvent.resource_id == str(lease.reservation.attempt_id), + ) + ) + == "checker.post_submit.finalize" + ) return await outbox_append(self.session).append(value) executor._outbox = ObservingAppend @@ -327,7 +334,10 @@ async def test_unreadable_stored_bytes_terminalize_and_replay( ): from tests.test_local_artifact_store import object_path - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture( + tmp_path, isolated_database_env, post_submit_registry_spec=post_registry_spec(), + ) as h: + assert len(h.request.policy.entries) == 1 reservation = await reserve(h) digest = h.request.content_sha256 path = object_path(h.settings.artifact_local_root, f"sha256/{digest[7:9]}/{digest[9:]}") @@ -338,12 +348,13 @@ async def test_unreadable_stored_bytes_terminalize_and_replay( path.chmod(0o600) path.write_bytes(bytes([h.data[0] ^ 1]) + h.data[1:]) path.chmod(0o400) - executor = live_executor(h) + selected_execution = ControlledSelectedExecution() + executor = live_executor(h, selected_execution=selected_execution) async def forbidden_checker(*args, **kwargs): pytest.fail("unverified stored bytes reached a checker") - monkeypatch.setattr(executor._registry, "run", forbidden_checker) + monkeypatch.setattr(selected_execution, "execute", forbidden_checker) # The byte failure cannot bypass the separate finalization authority. pending = [] finalize = executor.finalize @@ -366,7 +377,7 @@ async def retain_finalization(facts): result = await finalize(pending[0]) assert result.outcome == "infrastructure_failed" assert result.infrastructure_failure_code == "material_unavailable" - assert result.member_results == () + assert result.execution is None assert not h.preparation._active assert list((h.scratch / "workspaces").iterdir()) == [] opened = len(h.store.opens) @@ -375,12 +386,16 @@ async def retain_finalization(facts): assert len(h.store.opens) == opened async with h.factory() as session, session.begin(): run = await session.get(CheckerRun, str(reservation.attempt_id)) - assert run.status == "infrastructure_failed" and run.failure_code == "material_unavailable" + assert ( + run.status == "infrastructure_failed" and run.failure_code == "material_unavailable" + ) assert run.material_custody is None and run.completion_event_id is None assert run.finalize_evidence_id is not None assert run.routing_recommendation == "not_evaluated" assert await session.scalar(select(func.count()).select_from(CheckerResult)) == 0 - assert list(await session.scalars(select(OutboxEvent.event_id))) == [h.created.evaluation_event_id] + assert list(await session.scalars(select(OutboxEvent.event_id))) == [ + h.created.evaluation_event_id + ] with pytest.raises(CheckerExecutionUnavailable, match="current_result"): await evaluation_coordinator(session).read_current_result(h.request) @@ -392,17 +407,11 @@ async def test_nonrecordable_material_failure_leaves_attempt_recoverable( import asyncio from app.modules.artifacts.preparation import ArtifactScratchIntegrityError from app.modules.checkers.api.materialization import ( - PostSubmissionMaterializationUnavailable, PostSubmissionMaterializationFailure, + PostSubmissionMaterializationUnavailable, + PostSubmissionMaterializationFailure, ) async with material_fixture(tmp_path, isolated_database_env) as h: - if failure == "cleanup": - from tests.checkers.post_submit.support import change_request - - h.request = change_request(h.request, evaluation_request_id=new_record_id(), - evaluation_generation=2, structural_input=h.request.structural_input.model_copy( - update={"manifest": ()}, - )) reservation = await reserve(h) executor = live_executor(h) if failure == "authority": @@ -424,6 +433,10 @@ async def fail_close(prepared): await original_close(prepared) raise ArtifactScratchIntegrityError("controlled close failure") + async def fail_inspect(prepared, inspector): + raise PostSubmissionMaterializationFailure("post_submit_material_manifest_mismatch") + + monkeypatch.setattr(PreparedArtifact, "inspect", fail_inspect) monkeypatch.setattr(PreparedArtifact, "close", fail_close) else: expected = { @@ -449,4 +462,6 @@ async def fail_prepare(*args, **kwargs): assert run.status == "running" and run.result_json is None assert run.finalize_evidence_id is None and run.completion_event_id is None assert await session.scalar(select(func.count()).select_from(CheckerResult)) == 0 - assert list(await session.scalars(select(OutboxEvent.event_id))) == [h.created.evaluation_event_id] + assert list(await session.scalars(select(OutboxEvent.event_id))) == [ + h.created.evaluation_event_id + ] diff --git a/backend/tests/checkers/execution/test_material_lineage.py b/backend/tests/checkers/execution/test_material_lineage.py index 6167f3837..a07a24e43 100644 --- a/backend/tests/checkers/execution/test_material_lineage.py +++ b/backend/tests/checkers/execution/test_material_lineage.py @@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError from app.modules.checkers.models import CheckerRun, CheckerResult -from app.modules.checkers.post_submit_contracts import make_post_submit_result +from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_result from app.modules.outbox.models import OutboxEvent from tests.post_submit_materialization_helpers import material_fixture from .support import live_executor, reserve @@ -17,8 +17,8 @@ async def terminal_facts(h, lease, outcome): facts = await final_facts(h, lease) if outcome == "infrastructure_failed": body = facts.result.model_dump(exclude={"result_digest"}) - body.update(outcome=outcome, member_results=(), infrastructure_failure_code="implementation_unavailable") - facts = facts.model_copy(update={"result": make_post_submit_result(**body)}) + body.update(outcome=outcome, execution=None, infrastructure_failure_code="implementation_unavailable") + facts = facts.model_copy(update={"result": make_selected_post_submit_result(**body)}) return facts diff --git a/backend/tests/checkers/execution/test_material_migration.py b/backend/tests/checkers/execution/test_material_migration.py index 32a20326f..f88256619 100644 --- a/backend/tests/checkers/execution/test_material_migration.py +++ b/backend/tests/checkers/execution/test_material_migration.py @@ -8,20 +8,18 @@ from sqlalchemy import text from sqlalchemy.exc import IntegrityError +from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.db import session as db_session -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config from tests.historical_submission_fixtures import historical_material_fixture from types import SimpleNamespace -from app.modules.checkers.post_submit_contracts import make_post_submit_result -from tests.checkers.post_submit.support import change_request from .historical_execution import ( - historical_reserve, - historical_lease, - historical_terminal_facts, - historical_write_terminal, + seed_archived_checker_request, + seed_archived_running_checker, + archived_terminal_rows, + seed_archived_terminal, ) pytestmark = pytest.mark.postgres_schema_contract @@ -58,39 +56,46 @@ async def test_retained_material_upgrade( ): with migration_lock(): await predecessor_database(isolated_database_env) - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_material_fixture( - tmp_path, isolated_database_env, provision_checker=False + tmp_path, isolated_database_env ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) - await historical_reserve(h) - lease = await historical_lease(h) - facts = historical_terminal_facts(h, lease, outcome) - material = facts.material.model_dump(mode="json") + await seed_archived_checker_request(h) + lease = await seed_archived_running_checker(h) + material = dict(h.archived_material) if not valid: # A real but wrong admission is covered separately in direct SQL # tests. This retained 0008 row proves no fabricated ID backfill. - material["admission_id"] = str(h.request.submission_id) + material["admission_id"] = h.request["submission_id"] + rows = archived_terminal_rows( + h, lease, outcome=outcome, + members=h.archived_members if outcome == "completed" else (), + counts=h.archived_counts if outcome == "completed" else dict(passed=0, warning=0, failed=0, blocking=0), + routing_recommendation="allow_review" if outcome == "completed" else "not_evaluated", + failure_code=None if outcome == "completed" else "implementation_unavailable", + material=material, + ) async with h.factory() as session, session.begin(): - await historical_write_terminal(session, facts, material) + await seed_archived_terminal(session, rows) if valid: # Preserve superseded completed/failed history and a current # failure that never received material, plus later replica loss. successor = SimpleNamespace(**vars(h)) - successor.request = change_request(h.request, evaluation_generation=2, - evaluation_request_id=new_record_id()) - await historical_reserve(successor) - next_lease = await historical_lease(successor) - empty = historical_terminal_facts(successor, next_lease, "infrastructure_failed") - body = empty.result.model_dump(exclude={"result_digest"}) - body["infrastructure_failure_code"] = "material_unavailable" - empty = empty.model_copy(update={"result": make_post_submit_result(**body), "material": None}) + request_body = {key: value for key, value in h.request.items() if key != "request_sha256"} + request_body.update(evaluation_generation=2, evaluation_request_id=str(new_record_id())) + successor.request = request_body | {"request_sha256": canonical_json_hash(request_body)} + await seed_archived_checker_request(successor) + next_lease = await seed_archived_running_checker(successor) + empty = archived_terminal_rows( + successor, next_lease, outcome="infrastructure_failed", members=(), + counts=dict(passed=0, warning=0, failed=0, blocking=0), + routing_recommendation="not_evaluated", failure_code="material_unavailable", material=None, + ) async with h.factory() as session, session.begin(): - await historical_write_terminal(session, empty, None) + await seed_archived_terminal(session, empty) await session.execute(text( "update artifact_replicas set availability_state='unavailable', " "content_id=(select id from artifact_contents where id<>:content limit 1) where id=:id" - ), {"id": h.replica_id, "content": facts.material.content_id}) + ), {"id": h.replica_id, "content": h.archived_material["content_id"]}) before = await retained_snapshot(h.factory) assert before[1] == "0008_checker_execution" if valid: @@ -115,15 +120,16 @@ async def test_upgrade_excludes_writer_until_guard_is_installed( with migration_lock(): await predecessor_database(isolated_database_env) - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_material_fixture( - tmp_path, isolated_database_env, provision_checker=False + tmp_path, isolated_database_env ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) - await historical_reserve(h) - lease = await historical_lease(h) - facts = historical_terminal_facts(h, lease, "completed") - material = facts.material.model_dump(mode="json") | {"admission_id": str(h.request.submission_id)} + await seed_archived_checker_request(h) + lease = await seed_archived_running_checker(h) + material = dict(h.archived_material) | {"admission_id": h.request["submission_id"]} + rows = archived_terminal_rows( + h, lease, outcome="completed", members=h.archived_members, counts=h.archived_counts, + routing_recommendation="allow_review", failure_code=None, material=material, + ) scanned, resume = threading.Event(), threading.Event() execute = Operations.execute @@ -142,7 +148,7 @@ def paused_execute(operations, sql, *args, **kwargs): async def write(): async with h.factory() as session, session.begin(): await pid.put(await session.scalar(text("select pg_backend_pid()"))) - await historical_write_terminal(session, facts, material) + await seed_archived_terminal(session, rows) try: assert await asyncio.to_thread(scanned.wait, 10), "migration did not reach preflight" @@ -164,7 +170,7 @@ async def write(): await asyncio.wait_for(writer, 10) async with h.factory() as session: assert await session.scalar(text("select status from checker_runs where id=:id"), - {"id": str(facts.result.attempt_id)}) == "running" + {"id": rows["run"]["id"]}) == "running" finally: resume.set() await asyncio.gather(migration, *([writer] if writer is not None else []), return_exceptions=True) diff --git a/backend/tests/checkers/execution/test_migration.py b/backend/tests/checkers/execution/test_migration.py index 89bac29fd..22b885c46 100644 --- a/backend/tests/checkers/execution/test_migration.py +++ b/backend/tests/checkers/execution/test_migration.py @@ -9,7 +9,6 @@ from sqlalchemy.exc import IntegrityError from app.core.identifiers import new_record_id -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config, current_schema_revision from tests.historical_submission_fixtures import historical_material_fixture @@ -29,13 +28,11 @@ async def test_migration_refuses_retained_history(tmp_path, isolated_database_en finally: await conn.close() await asyncio.to_thread(command.upgrade, _config(), "0007_checker_output_custody") - original_columns = await add_current_art_seed_column(isolated_database_env) - async with historical_material_fixture(tmp_path, isolated_database_env, provision_checker=False) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) + async with historical_material_fixture(tmp_path, isolated_database_env) as h: async with h.factory() as session, session.begin(): source = await session.scalar( text("select to_jsonb(s) from submissions s where id=:id"), - {"id": str(h.request.submission_id)}, + {"id": h.request["submission_id"]}, ) values = { key: value @@ -72,9 +69,9 @@ async def test_migration_refuses_retained_history(tmp_path, isolated_database_en trigger_auth_source="test", attempt_number=1, is_current_for_submission=True, - package_hash=h.request.content_sha256, + package_hash=h.request["content_sha256"], artifact_hash_manifest=source["artifact_hash_manifest"], - artifact_manifest_hash=h.manifest.sha256, + artifact_manifest_hash=h.archived_material["semantic_manifest_sha256"], passed_count=0, warning_count=0, failed_count=0, diff --git a/backend/tests/checkers/execution/test_results.py b/backend/tests/checkers/execution/test_results.py deleted file mode 100644 index 268cad87f..000000000 --- a/backend/tests/checkers/execution/test_results.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Complete locked-policy classification; one bad member cannot hide in a pass.""" - -import pytest - -from app.modules.checkers.api.post_submit import PostSubmitMemberResult -from app.modules.checkers.execution_results import classify_result -from tests.checkers.post_submit.support import request -from tests.checkers.post_submit.test_result_contract import result - - -@pytest.mark.parametrize( - "failed_ids,expected", - [ - ((), "allow_review"), - (("check_submission_packet",), "needs_revision"), - (("check_policy_context_present",), "task_setup_blocked"), - (("check_submission_packet", "check_policy_context_present"), "task_setup_blocked"), - (("check_low_quality_generated_artifacts",), "allow_review"), - ], -) -def test_routing_uses_complete_locked_policy(failed_ids, expected): - source = request() - members = [] - matched = set() - for entry, passed in zip(source.policy.entries, result(source).member_results, strict=True): - definition = source.catalogue.definition(entry.checker_id, entry.definition_version) - if entry.checker_id in failed_ids: - matched.add(entry.checker_id) - members.append( - PostSubmitMemberResult( - checker_id=entry.checker_id, - definition_version=entry.definition_version, - implementation_version=entry.implementation_version, - status=definition.failure_status, - code=definition.failure_code, - failure_category=definition.failure_category, - severity=definition.failure_severity, - ) - ) - else: - members.append(passed) - assert matched == set(failed_ids), "case must exercise a registered member" - actual = classify_result(source, result(source, member_results=tuple(members))) - assert actual.routing == expected - assert actual.passed + actual.warning + actual.failed == len(source.policy.entries) - assert actual.blocking == sum(m.status == "failed" for m in members) - - -def test_incomplete_members_cannot_classify(): - source = request() - with pytest.raises(ValueError, match="members mismatch"): - classify_result(source, result(source, member_results=result(source).member_results[:-1])) - - -@pytest.mark.parametrize("strict", [False, True]) -def test_locked_medium_severity_can_block_placeholder_warning(strict): - from app.modules.projects.post_submit_policy import ( - build_project_post_submit_checker_spec, - compile_project_post_submit_checker_spec, - ) - from tests.checkers.post_submit.support import change_request - - source = request() - policy = compile_project_post_submit_checker_spec( - project_id=str(source.project_id), - guide_version="v1", - spec=build_project_post_submit_checker_spec( - project_id=str(source.project_id), - guide_version="v1", - blocking_severities=["critical", "high", "medium"] if strict else ["critical", "high"], - ), - ) - source = change_request( - source, - policy=policy, - expected_context=source.expected_context.model_copy( - update={"post_policy_hash": policy.policy_hash} - ), - ) - members = list(result(source).member_results) - idx = next( - index - for index, item in enumerate(members) - if item.checker_id == "check_low_quality_generated_artifacts" - ) - definition = source.catalogue.definition( - members[idx].checker_id, members[idx].definition_version - ) - members[idx] = PostSubmitMemberResult( - checker_id=definition.capability_id, - implementation_version=definition.implementation_version, - status="warning", - severity="medium", - code=definition.failure_code, - failure_category=definition.failure_category, - ) - classified = classify_result(source, result(source, member_results=tuple(members))) - assert classified.routing == ("needs_revision" if strict else "allow_review") - assert classified.warning == 1 and classified.failed == 0 and classified.blocking == int(strict) diff --git a/backend/tests/checkers/execution/test_storage.py b/backend/tests/checkers/execution/test_storage.py index 733962d1f..73907c9ec 100644 --- a/backend/tests/checkers/execution/test_storage.py +++ b/backend/tests/checkers/execution/test_storage.py @@ -13,6 +13,38 @@ from tests.post_submit_materialization_helpers import material_fixture from .support import reserve, live_executor from .test_concurrency import final_facts +from tests.checkers.external.support import post_registry_spec + + +async def test_failed_external_image_history_uses_normalized_result_status( + tmp_path, isolated_database_env, +): + from app.modules.checkers.history import CheckerHistoryRepository + + async with material_fixture( + tmp_path, isolated_database_env, post_submit_registry_spec=post_registry_spec() + ) as h: + await reserve(h) + executor = live_executor(h) + lease, _ = await executor._claim(h.request) + facts = await final_facts(h, lease, verdict="failed") + await executor.finalize(facts) + async with h.factory() as session: + for manager in (False, True): + rows, more = await CheckerHistoryRepository(session).read( + submission_id=h.request.submission_id, + task_id=h.request.task_id, + manager=manager, + ) + assert not more and len(rows) == 1 + member, = rows[0].results + assert member.checker_name == h.request.policy.entries[0].requirement_id + assert member.status == "failed" and member.severity == "error" + assert member.worker_message == "Project check failed." + assert member.worker_suggested_fix == "Review the project guide and check findings." + if manager: + assert member.blocks_review is True + assert member.message == member.worker_message @pytest.mark.parametrize("duplicate", ["request", "generation"]) @@ -55,31 +87,15 @@ async def test_duplicate_request_and_generation_rejected( async def test_terminal_member_and_routing_custody(tmp_path, isolated_database_env, monkeypatch): - from app.modules.checkers.api.post_submit import PostSubmitMemberResult - from app.modules.checkers.post_submit_contracts import make_post_submit_result import app.modules.checkers.execution as execution - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture(tmp_path, isolated_database_env, + post_submit_registry_spec=post_registry_spec()) as h: await reserve(h) executor = live_executor(h) lease, _ = await executor._claim(h.request) - facts = await final_facts(h, lease) - members = list(facts.result.member_results) - definition = h.request.catalogue.definition( - members[0].checker_id, members[0].definition_version - ) - members[0] = PostSubmitMemberResult( - checker_id=definition.capability_id, - implementation_version=definition.implementation_version, - status=definition.failure_status, - severity=definition.failure_severity, - code=definition.failure_code, - failure_category=definition.failure_category, - ) - body = facts.result.model_dump(exclude={"result_digest"}) - body["member_results"] = tuple(members) - blocked = make_post_submit_result(**body) - facts = facts.model_copy(update={"result": blocked}) + facts = await final_facts(h, lease, verdict="failed") + blocked = facts.result canonical = execution.classify_result assert canonical(h.request, blocked).routing == "needs_revision" monkeypatch.setattr( @@ -108,21 +124,23 @@ async def test_execution_custody_rejects_mutation(tmp_path, isolated_database_en original = await session.get(CheckerRun, str(receipt.attempt_id)) before = getattr(original, field) replacement = ( - "{}" + request_text(change_request(h.request, evaluation_request_id=new_record_id())) if field == "request_json" else 2 if field == "evaluation_generation" else str(new_record_id()) ) setattr(original, field, replacement) - with pytest.raises(IntegrityError, match="checker run custody is immutable"): + with pytest.raises(IntegrityError, match="checker run custody is immutable") as refused: await session.flush() + assert refused.value.orig.sqlstate == "23514" await session.rollback() assert getattr(await session.get(CheckerRun, str(receipt.attempt_id)), field) == before async def test_unfinished_members_cannot_commit(tmp_path, isolated_database_env): - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture(tmp_path, isolated_database_env, + post_submit_registry_spec=post_registry_spec()) as h: await reserve(h) lease, _ = await live_executor(h)._claim(h.request) async with h.factory() as session: @@ -143,36 +161,37 @@ async def test_member_shape_and_complete_set_enforced_in_database( from sqlalchemy import insert from app.modules.checkers.execution_repository import ExecutionRepository - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture(tmp_path, isolated_database_env, + post_submit_registry_spec=post_registry_spec()) as h: await reserve(h) executor = live_executor(h) lease, _ = await executor._claim(h.request) facts = await final_facts(h, lease) - first = facts.result.member_results[0] + first = facts.result.execution.external_members[0] values = dict( id=str(new_record_id()), checker_run_id=str(lease.reservation.attempt_id), task_id=str(h.request.task_id), submission_id=str(h.request.submission_id), member_order=0, - checker_name=first.checker_id, - definition_version=first.definition_version, - implementation_version=first.implementation_version, - status=first.status, - code=first.code, - failure_category=first.failure_category, - severity=first.severity, + checker_name=first.requirement_id, + definition_version=first.result.schema_version, + implementation_version=first.registry_entry_digest, + status=first.result.verdict, + code="passed", + failure_category="none", + severity="info", counters=[], ) invalid = [ ({"checker_name": "unselected_checker"}, "differs from selected definition"), ( - {"member_order": len(facts.result.member_results)}, + {"member_order": len(facts.result.execution.external_members)}, "differs from selected definition", ), - ({"status": "passed", "code": "packet_fields_missing"}, "passing member shape invalid"), - ({"counters": [{"key": "invalid_count", "value": 1025}]}, "counters invalid"), - ({"counters": [{"key": "invalid_count"}]}, "counters invalid"), + ({"status": "passed", "code": "unexpected_pass_code"}, "differs from selected definition"), + ({"counters": [{"metric": 1}]}, "differs from selected definition"), + ({"counters": {"metric": 1}}, "differs from selected definition"), ({"task_id": str(new_record_id())}, "fk_checker_results_run_ownership"), ] async with h.factory() as session: @@ -188,7 +207,7 @@ async def test_member_shape_and_complete_set_enforced_in_database( async def omit_last(repo, run, result): await original( - repo, run, result.model_copy(update={"member_results": result.member_results[:-1]}) + repo, run, result.model_copy(update={"execution": replace(result.execution, required_external_count=0, external_members=())}) ) monkeypatch.setattr(ExecutionRepository, "write_members", omit_last) @@ -207,23 +226,24 @@ async def test_consistently_short_result_cannot_omit_selected_policy_member( tmp_path, isolated_database_env, monkeypatch ): from app.modules.checkers.execution_results import ResultClassification - from app.modules.checkers.post_submit_contracts import make_post_submit_result + from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_result from app.modules.outbox.models import OutboxEvent from app.modules.checkers.api.execution import COMPLETION_EVENT from sqlalchemy import func from sqlalchemy import text from . import material_storage_helpers - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture(tmp_path, isolated_database_env, + post_submit_registry_spec=post_registry_spec()) as h: await reserve(h) executor = live_executor(h) lease, _ = await executor._claim(h.request) valid = await final_facts(h, lease) - assert len(valid.result.member_results) > 1 - assert all(member.status == "passed" for member in valid.result.member_results) + assert len(valid.result.execution.external_members) == 1 + assert all(member.result.verdict == "passed" for member in valid.result.execution.external_members) body = valid.result.model_dump(exclude={"result_digest"}) - body["member_results"] = valid.result.member_results[:-1] - short = make_post_submit_result(**body) + body["execution"] = replace(valid.result.execution, required_external_count=0, external_members=()) + short = make_selected_post_submit_result(**body) facts = valid.model_copy(update={"result": short}) # Simulate a faulty validator/compiler accepting a self-consistent short # result. Persisted members, counts, result digest and event all agree; @@ -232,15 +252,28 @@ async def test_consistently_short_result_cannot_omit_selected_policy_member( def faulty_classifier(request, result): assert request == h.request and result == short - return ResultClassification("allow_review", len(short.member_results), 0, 0, 0) + return ResultClassification("allow_review", len(short.execution.external_members), 0, 0, 0) monkeypatch.setattr(material_storage_helpers, "classify_result", faulty_classifier) + # This SQL-owner control deliberately simulates an application accepting + # only this counterfeit, while its AUTH digest binds the same counterfeit + # facts. A mismatched valid AUTH receipt must not mask policy-set custody. + from app.modules.checkers.api.selected_post_submit import SelectedPostSubmissionEvaluationResult + validate_request = SelectedPostSubmissionEvaluationResult.validate_request + + def faulty_request_validator(result, request): + if result == short and request == h.request: + return None + return validate_request(result, request) + + monkeypatch.setattr(SelectedPostSubmissionEvaluationResult, "validate_request", faulty_request_validator) async with h.factory() as session: await material_storage_helpers.write_terminal( - session, facts, facts.material.model_dump(mode="json"), authorized_facts=valid, + session, facts, facts.material.model_dump(mode="json"), ) - with pytest.raises(IntegrityError, match="checker completed custody or routing invalid"): + with pytest.raises(IntegrityError, match="checker terminal selected policy differs") as refused: await session.execute(text("SET CONSTRAINTS public.checker_terminal_custody IMMEDIATE")) + assert refused.value.orig.sqlstate == "23514" await session.rollback() async with h.factory() as session: assert ( @@ -259,20 +292,94 @@ def faulty_classifier(request, result): assert await executor.finalize(valid) == valid.result +@pytest.mark.parametrize("substitution", ["registry_entry_id", "image_digest"]) +async def test_same_count_result_cannot_substitute_locked_image_identity( + tmp_path, isolated_database_env, monkeypatch, substitution +): + from app.modules.checkers.api.external import make_external_checker_execution_result + from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmissionEvaluationResult, + make_selected_post_submit_result, + ) + from sqlalchemy import func, text + from . import material_storage_helpers + + async with material_fixture( + tmp_path, isolated_database_env, post_submit_registry_spec=post_registry_spec() + ) as h: + await reserve(h) + executor = live_executor(h) + lease, _ = await executor._claim(h.request) + valid = await final_facts(h, lease) + member, = valid.result.execution.external_members + if substitution == "registry_entry_id": + foreign_id = new_record_id() + normalized_body = member.result.model_dump(exclude={"result_digest"}) + normalized_body["registry_entry_id"] = foreign_id + changed = replace( + member, registry_entry_id=foreign_id, + result=make_external_checker_execution_result(**normalized_body), + ) + else: + foreign_image = "sha256:" + ( + "c" if member.isolation.platform_manifest_digest != "sha256:" + "c" * 64 else "d" + ) * 64 + changed = replace(member, isolation=replace( + member.isolation, platform_manifest_digest=foreign_image, + )) + body = valid.result.model_dump(exclude={"result_digest"}) + body["execution"] = replace(valid.result.execution, external_members=(changed,)) + counterfeit = make_selected_post_submit_result(**body) + assert counterfeit.execution.required_external_count == 1 + assert counterfeit.execution.policy_sha256 == h.request.policy.policy_sha256 + with pytest.raises(ValueError, match="selected POST result request mismatch"): + counterfeit.validate_request(h.request) + facts = valid.model_copy(update={"result": counterfeit}) + canonical = SelectedPostSubmissionEvaluationResult.validate_request + + def faulty_validator(result, request): + if result == counterfeit and request == h.request: + return None + return canonical(result, request) + + monkeypatch.setattr(SelectedPostSubmissionEvaluationResult, "validate_request", faulty_validator) + async with h.factory() as session: + before_audits = await session.scalar(text("SELECT count(*) FROM public.audit_events")) + await material_storage_helpers.write_terminal( + session, facts, facts.material.model_dump(mode="json"), + ) + with pytest.raises(IntegrityError, match="checker terminal selected policy differs") as denied: + await session.execute(text("SET CONSTRAINTS ALL IMMEDIATE")) + assert denied.value.orig.sqlstate == "23514" + await session.rollback() + async with h.factory() as session: + run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) + assert run.status == "running" and run.result_json is None + assert run.finalize_evidence_id is None and run.completion_event_id is None + assert await session.scalar(select(func.count()).select_from(CheckerResult)) == 0 + assert await session.scalar(text("SELECT count(*) FROM public.audit_events")) == before_audits + assert await session.scalar(text( + "SELECT count(*) FROM public.outbox_events " + "WHERE event_type='PostSubmissionEvaluationCompleted'" + )) == 0 + monkeypatch.setattr(SelectedPostSubmissionEvaluationResult, "validate_request", canonical) + assert await executor.finalize(valid) == valid.result + + async def test_infrastructure_failure_code_is_closed_in_database(tmp_path, isolated_database_env): import json from sqlalchemy import update, func, text from app.core.hashing import canonical_json_hash - from app.modules.checkers.post_submit_contracts import make_post_submit_result + from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_result async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) lease, _ = await live_executor(h)._claim(h.request) - result = make_post_submit_result( + result = make_selected_post_submit_result( request_id=h.request.evaluation_request_id, request_digest=h.request.request_sha256, attempt_id=lease.reservation.attempt_id, result_id=lease.reservation.result_id, evaluation_generation=h.request.evaluation_generation, outcome="infrastructure_failed", - member_results=(), infrastructure_failure_code="material_unavailable", + execution=None, infrastructure_failure_code="material_unavailable", ) body = result.model_dump(mode="json", exclude={"result_digest"}) async with h.factory() as session: diff --git a/backend/tests/checkers/external/support.py b/backend/tests/checkers/external/support.py index 54a64107c..2897e3cba 100644 --- a/backend/tests/checkers/external/support.py +++ b/backend/tests/checkers/external/support.py @@ -10,15 +10,23 @@ POST_SUBMIT_INPUT_SCHEMA_ID, PRE_SUBMIT_INPUT_SCHEMA_ID, RESULT_SCHEMA_ID, + ExternalCheckerCapabilityDefinition, + ExternalCheckerCapabilityProjection, ExternalCheckerRegistryEntry, ExternalCheckerRegistrySpec, ExternalCheckerResourceLimits, ExternalCheckerSchema, + PostSubmitExternalCheckerMaterial, PostSubmitExternalCheckerIdentity, + PreSubmitExternalCheckerMaterial, PreSubmitExternalCheckerIdentity, external_checker_json_hash, make_external_checker_registration_request, ) +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_CHECKER_ID, + WORKSTREAM_DEFAULT_CHECKER_VERSION, +) SHA = "sha256:" + "a" * 64 @@ -49,9 +57,7 @@ def registry_spec( image_digest=SHA, configuration_schema=schema("acme.safe_archive.configuration"), input_schema=schema( - PRE_SUBMIT_INPUT_SCHEMA_ID - if phase == "pre_submit" - else POST_SUBMIT_INPUT_SCHEMA_ID, + PRE_SUBMIT_INPUT_SCHEMA_ID if phase == "pre_submit" else POST_SUBMIT_INPUT_SCHEMA_ID, required=("task_version",), ), output_schema=schema(RESULT_SCHEMA_ID), @@ -64,6 +70,19 @@ def registry_spec( ) +def post_registry_spec() -> ExternalCheckerRegistrySpec: + """Return the exact selected POST schema used by native execution fixtures.""" + values = registry_spec("post_submit").model_dump(mode="json") + document = {"type": "object"} + values["input_schema"] = ExternalCheckerSchema( + schema_id=POST_SUBMIT_INPUT_SCHEMA_ID, + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ).model_dump(mode="json") + return ExternalCheckerRegistrySpec.model_validate(values) + + def registry_entry( phase: str = "pre_submit", *, maximum_output_bytes: int = 32_768 ) -> ExternalCheckerRegistryEntry: @@ -79,6 +98,43 @@ def registry_entry( ) +def capability_projection( + *entries: ExternalCheckerRegistryEntry, +) -> ExternalCheckerCapabilityProjection: + """Build the one code-owned default plus exact immutable registry entries.""" + definitions = tuple( + sorted( + ( + ExternalCheckerCapabilityDefinition( + capability_id=WORKSTREAM_DEFAULT_CHECKER_ID, + capability_version=WORKSTREAM_DEFAULT_CHECKER_VERSION, + phase="pre_submit", + selectable=False, + ), + *( + ExternalCheckerCapabilityDefinition( + capability_id=entry.capability_id, + capability_version=entry.capability_version, + phase=entry.phase, + selectable=True, + registry_entry=entry, + ) + for entry in entries + ), + ), + key=lambda item: (item.phase, item.capability_id, item.capability_version), + ) + ) + body = { + "schema_version": "external_checker_capabilities.v1", + "definitions": [item.model_dump(mode="json") for item in definitions], + } + return ExternalCheckerCapabilityProjection( + definitions=definitions, + manifest_sha256=external_checker_json_hash(body), + ) + + def registration_request(phase: str = "pre_submit", *, actor_profile_id=None): return make_external_checker_registration_request( actor_profile_id=actor_profile_id or uuid4(), @@ -111,3 +167,35 @@ def execution_identity(phase: str = "pre_submit"): lease_generation=4, lease_expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), ) + + +def execution_material(identity): + """Build one genuine phase-specific custody value for a test identity.""" + if identity.phase == "pre_submit": + return PreSubmitExternalCheckerMaterial( + role="submission_archive", + prepared_generation_id=identity.prepared_generation_id, + attempt_id=identity.attempt_id, + attempt_request_digest=identity.attempt_request_digest, + archive_sha256=SHA, + archive_byte_count=4, + semantic_manifest_sha256=SHA, + media_type="application/zip", + ) + return PostSubmitExternalCheckerMaterial( + role="submission_archive", + submission_id=identity.submission_id, + submission_version=identity.submission_version, + binding_id=uuid4(), + content_id=uuid4(), + replica_id=uuid4(), + content_sha256=SHA, + byte_count=4, + media_type="application/zip", + evidence_id=uuid4(), + verification_receipt_id=uuid4(), + verification_job_id=uuid4(), + verification_generation=1, + namespace_fingerprint=SHA, + semantic_manifest_sha256=SHA, + ) diff --git a/backend/tests/checkers/external/test_contracts.py b/backend/tests/checkers/external/test_contracts.py index b640f6199..3bf9698c1 100644 --- a/backend/tests/checkers/external/test_contracts.py +++ b/backend/tests/checkers/external/test_contracts.py @@ -14,7 +14,7 @@ RESULT_SCHEMA_ID, ExternalCheckerContractError, ExternalCheckerFinding, - ExternalCheckerMaterial, + PreSubmitExternalCheckerMaterial, ExternalCheckerSchema, external_checker_json_hash, make_external_checker_execution_request, @@ -24,6 +24,7 @@ from tests.checkers.external.support import ( SHA, execution_identity, + execution_material, registry_entry, registry_spec, schema, @@ -121,23 +122,15 @@ def test_registration_request_digest_is_derived_and_substitution_fails() -> None @pytest.mark.parametrize("phase", ["pre_submit", "post_submit"]) def test_execution_request_binds_existing_phase_lineage_and_materials(phase: str) -> None: entry = registry_entry(phase) + identity = execution_identity(phase) request = make_external_checker_execution_request( registry=entry, - identity=execution_identity(phase), + identity=identity, configuration={}, configuration_sha256=external_checker_json_hash({}), input={"task_version": "v3"}, input_sha256=external_checker_json_hash({"task_version": "v3"}), - materials=( - ExternalCheckerMaterial( - role="submission_archive", - content_id=uuid4(), - replica_id=uuid4(), - sha256=SHA, - byte_count=4, - media_type="application/zip", - ), - ), + materials=(execution_material(identity),), ) assert request.identity.phase == phase with pytest.raises(ValidationError, match="input digest mismatch"): @@ -161,35 +154,39 @@ def test_execution_identity_does_not_accept_cross_phase_or_unknown_fields() -> N def test_execution_request_rejects_duplicate_material_roles() -> None: - material = ExternalCheckerMaterial( + identity = execution_identity() + material = PreSubmitExternalCheckerMaterial( role="submission_archive", - content_id=uuid4(), - replica_id=uuid4(), - sha256=SHA, - byte_count=4, + prepared_generation_id=identity.prepared_generation_id, + attempt_id=identity.attempt_id, + attempt_request_digest=identity.attempt_request_digest, + archive_sha256=SHA, + archive_byte_count=4, + semantic_manifest_sha256=SHA, media_type="application/zip", ) with pytest.raises(ValidationError, match="role is duplicated"): make_external_checker_execution_request( registry=registry_entry(), - identity=execution_identity(), + identity=identity, configuration={}, configuration_sha256=external_checker_json_hash({}), input={"task_version": "v3"}, input_sha256=external_checker_json_hash({"task_version": "v3"}), - materials=(material, material.model_copy(update={"content_id": uuid4()})), + materials=(material, material.model_copy(update={"archive_sha256": "sha256:" + "b" * 64})), ) def test_completed_and_infrastructure_results_are_closed_and_request_bound() -> None: + identity = execution_identity() request = make_external_checker_execution_request( registry=registry_entry(), - identity=execution_identity(), + identity=identity, configuration={}, configuration_sha256=external_checker_json_hash({}), input={"task_version": "v3"}, input_sha256=external_checker_json_hash({"task_version": "v3"}), - materials=(), + materials=(execution_material(identity),), ) completed = make_external_checker_execution_result( request_digest=request.request_digest, @@ -248,14 +245,15 @@ def test_completed_and_infrastructure_results_are_closed_and_request_bound() -> infrastructure_failure_code="network_error", ) + bounded_identity = execution_identity() bounded_request = make_external_checker_execution_request( registry=registry_entry(maximum_output_bytes=256), - identity=execution_identity(), + identity=bounded_identity, configuration={}, configuration_sha256=external_checker_json_hash({}), input={"task_version": "v3"}, input_sha256=external_checker_json_hash({"task_version": "v3"}), - materials=(), + materials=(execution_material(bounded_identity),), ) oversized_infrastructure = make_external_checker_execution_result( request_digest=bounded_request.request_digest, diff --git a/backend/tests/checkers/external/test_policy.py b/backend/tests/checkers/external/test_policy.py new file mode 100644 index 000000000..4893ce7e1 --- /dev/null +++ b/backend/tests/checkers/external/test_policy.py @@ -0,0 +1,382 @@ +"""Approved bindings resolve to exact immutable external registry facts.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from app.core.identifiers import new_record_id +from app.interfaces.project_agents import ( + AtomicGuideRequirement, + CapabilityParameter, + CompilationStage, + PostSubmissionBindingProposal, + PreSubmissionBindingProposal, + RequirementDisposition, +) +from app.modules.checkers.api.external import ( + ExternalCheckerRegistryEntry, + ExternalCheckerSchema, + external_checker_json_hash, + ExternalCheckerRegistryUnavailable, +) +from app.modules.checkers.api.external_policy import ( + ExternalCheckerPolicyError, + LockedWorkstreamDefault, + MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS, + SELECTED_POST_MATERIALIZATION_BUDGET_MS, + compile_external_checker_policies, + selected_post_submit_execution_budget_ms, + selected_pre_submit_execution_budget_ms, +) +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 + + +def _entry( + capability_id: str, + phase: str, + configuration_schema=None, + *, + deadline_ms: int | None = None, +) -> ExternalCheckerRegistryEntry: + changes = {"capability_id": capability_id} + if configuration_schema is not None: + changes["configuration_schema"] = configuration_schema + spec = registry_spec(phase).model_copy(update=changes) + if deadline_ms is not None: + spec = spec.model_copy( + update={"resources": spec.resources.model_copy(update={"deadline_ms": deadline_ms})} + ) + # Revalidate after changing one digest-bound specification field. + spec = type(spec).model_validate(spec.model_dump()) + return ExternalCheckerRegistryEntry( + **spec.model_dump(), + registry_entry_id=new_record_id(), + entry_digest=spec.spec_digest, + registration_operation_id=uuid4(), + registered_by_actor_profile_id=uuid4(), + authorization_decision_event_id=uuid4(), + created_at=datetime.now(UTC), + ) + + +class _Registry: + def __init__(self, entries): + self.entries = { + (entry.capability_id, entry.capability_version, entry.phase): entry for entry in entries + } + self.calls = [] + + async def read_identity(self, **identity): + self.calls.append(identity) + try: + return self.entries[ + ( + identity["capability_id"], + identity["capability_version"], + identity["phase"], + ) + ] + except KeyError as exc: + raise ExternalCheckerRegistryUnavailable( + "external_checker_registry_unavailable" + ) from exc + + +def _proposal(): + pre = AtomicGuideRequirement( + requirement_id="safe_archive", + statement="Inspect the delivered archive.", + disposition=RequirementDisposition.SUPPORTED_PRE_SUBMIT, + ) + post = AtomicGuideRequirement( + requirement_id="quality_review", + statement="Evaluate the submitted work.", + disposition=RequirementDisposition.SUPPORTED_POST_SUBMIT, + ) + return result().model_copy( + update={ + "requirements": (pre, post), + "pre_submit_bindings": ( + PreSubmissionBindingProposal( + requirement_id=pre.requirement_id, + capability_id="acme.safe_archive", + capability_version="v1.2.3", + stage=CompilationStage.PRE_SUBMIT, + ), + ), + "post_submit_bindings": ( + PostSubmissionBindingProposal( + requirement_id=post.requirement_id, + capability_id="acme.quality_review", + capability_version="v1.2.3", + stage=CompilationStage.POST_SUBMIT, + ), + ), + } + ) + + +@pytest.mark.asyncio +async def test_compilation_locks_exact_phase_registry_entries_and_default(): + entries = ( + _entry("acme.safe_archive", "pre_submit"), + _entry("acme.quality_review", "post_submit"), + ) + registry = _Registry(entries) + + pre, post = await compile_external_checker_policies(_proposal(), registry) + + assert pre.workstream_default.checker_id == "workstream.default" + assert [(item.requirement_id, item.registry.registry_entry_id) for item in pre.entries] == [ + ("safe_archive", entries[0].registry_entry_id) + ] + assert [(item.requirement_id, item.registry.registry_entry_id) for item in post.entries] == [ + ("quality_review", entries[1].registry_entry_id) + ] + assert pre.entries[0].configuration == {} + assert post.entries[0].configuration == {} + assert [call["phase"] for call in registry.calls] == ["pre_submit", "post_submit"] + + +@pytest.mark.asyncio +async def test_compilation_refuses_missing_or_wrong_phase_registry_identity(): + proposal = _proposal() + with pytest.raises(ExternalCheckerRegistryUnavailable): + await compile_external_checker_policies(proposal, _Registry(())) + + wrong = _entry("acme.safe_archive", "post_submit") + with pytest.raises(ExternalCheckerRegistryUnavailable): + await compile_external_checker_policies(proposal, _Registry((wrong,))) + + class _SubstitutedRegistry: + async def read_identity(self, **_identity): + return _entry("acme.substituted", "pre_submit") + + with pytest.raises(ExternalCheckerPolicyError, match="identity"): + await compile_external_checker_policies(proposal, _SubstitutedRegistry()) + + +@pytest.mark.asyncio +async def test_compilation_refuses_requirement_and_registry_digest_substitution(): + proposal = _proposal() + wrong_requirement = proposal.model_copy( + update={ + "pre_submit_bindings": ( + proposal.pre_submit_bindings[0].model_copy( + update={"requirement_id": "quality_review"} + ), + ) + } + ) + with pytest.raises(ExternalCheckerPolicyError, match="does not cover"): + await compile_external_checker_policies( + wrong_requirement, + _Registry((_entry("acme.safe_archive", "pre_submit"),)), + ) + + entry = _entry("acme.safe_archive", "pre_submit") + corrupt = entry.model_copy(update={"entry_digest": "sha256:" + "f" * 64}) + with pytest.raises(ValueError, match="entry digest"): + await compile_external_checker_policies(proposal, _Registry((corrupt,))) + + +@pytest.mark.asyncio +async def test_compilation_refuses_blocked_capability_coverage(): + proposal = _proposal().model_copy( + update={ + "status": "guide_blocked", + "submission_artifact_policy": None, + "capability_suggestions": (), + } + ) + with pytest.raises(ExternalCheckerPolicyError, match="coverage"): + await compile_external_checker_policies(proposal, _Registry(())) + + +def test_locked_default_refuses_substituted_behavior_digest(): + with pytest.raises(ValueError, match="behavior"): + LockedWorkstreamDefault(behavior_sha256="sha256:" + "0" * 64) + + +@pytest.mark.asyncio +async def test_selected_pre_budget_binds_ordered_deadlines_to_art_custody(): + pre, _ = await compile_external_checker_policies( + _proposal(), + _Registry( + ( + _entry("acme.safe_archive", "pre_submit"), + _entry("acme.quality_review", "post_submit"), + ) + ), + ) + assert selected_pre_submit_execution_budget_ms(pre, artifact_deadline_ms=150_000) == 150_000 + with pytest.raises(ExternalCheckerPolicyError, match="exceeds ART"): + selected_pre_submit_execution_budget_ms(pre, artifact_deadline_ms=149_999) + + +@pytest.mark.asyncio +async def test_selected_post_maximum_fits_default_art_custody_and_rejects_overrun(): + image_deadline = ( + MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS + - SELECTED_POST_MATERIALIZATION_BUDGET_MS + - EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS + ) + _, policy = await compile_external_checker_policies( + _proposal(), + _Registry( + ( + _entry("acme.safe_archive", "pre_submit"), + _entry( + "acme.quality_review", + "post_submit", + deadline_ms=image_deadline, + ), + ) + ), + ) + 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 ( + limits.total_deadline_seconds + limits.cleanup_margin_seconds + < limits.reservation_ttl_seconds + ) + with pytest.raises(ExternalCheckerPolicyError, match="ART custody"): + selected_post_submit_execution_budget_ms( + policy, + artifact_deadline_ms=MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS - 1, + ) + + too_slow = policy.model_copy( + update={ + "entries": ( + policy.entries[0].model_copy( + update={ + "registry": _entry( + "acme.quality_review", + "post_submit", + deadline_ms=image_deadline + 1, + ) + } + ), + ) + } + ) + with pytest.raises(ExternalCheckerPolicyError, match="durable worker"): + selected_post_submit_execution_budget_ms(too_slow) + + +@pytest.mark.asyncio +async def test_pre_configuration_is_schema_checked_and_locked_as_json(): + document = { + "type": "object", + "properties": { + "threshold": {"type": "integer", "minimum": 1}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["threshold", "tags"], + "additionalProperties": False, + } + schema = ExternalCheckerSchema( + 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"), + )) + proposal = _proposal() + parameters = ( + CapabilityParameter(name="threshold", value=3), + CapabilityParameter(name="tags", value=("alpha", "beta")), + ) + binding = proposal.pre_submit_bindings[0].model_copy(update={"parameters": parameters}) + configured = proposal.model_copy(update={"pre_submit_bindings": (binding,)}) + pre, _ = await compile_external_checker_policies(configured, registry) + assert pre.entries[0].configuration == {"threshold": 3, "tags": ["alpha", "beta"]} + assert pre.entries[0].configuration_sha256 == external_checker_json_hash( + {"threshold": 3, "tags": ["alpha", "beta"]} + ) + for invalid in ((), (CapabilityParameter(name="threshold", value="three"),)): + 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, + ) + + +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", + } + 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)}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("aggregate", [False, True]) +async def test_policy_compilation_refuses_unrunnable_post_images(aggregate: bool): + proposal = _proposal() + entries = [_entry("acme.safe_archive", "pre_submit")] + if aggregate: + binding = proposal.post_submit_bindings[0] + requirement = next( + 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), + ]) + else: + entries.append(_entry("acme.quality_review", "post_submit", deadline_ms=3_600_000)) + with pytest.raises(ValueError, match="durable worker deadline"): + await compile_external_checker_policies(proposal, _Registry(entries)) + + +@pytest.mark.asyncio +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", + }), + ), + }) + entries = [ + _entry("acme.safe_archive", "pre_submit", deadline_ms=3_600_000), + _entry("acme.secondary_archive", "pre_submit", deadline_ms=3_600_000), + _entry("acme.quality_review", "post_submit"), + ] + with pytest.raises(ValueError, match="ART custody deadline"): + await compile_external_checker_policies(proposal, _Registry(entries)) diff --git a/backend/tests/checkers/external/test_registry.py b/backend/tests/checkers/external/test_registry.py index a02ce7730..51b52b075 100644 --- a/backend/tests/checkers/external/test_registry.py +++ b/backend/tests/checkers/external/test_registry.py @@ -35,6 +35,13 @@ class _Session: def __init__(self, active: bool = True) -> None: self.root = SimpleNamespace(is_active=active) if active else None self.sync_session = self + self.rows = [] + + async def get(self, model, entry_id, *, populate_existing): + from app.modules.checkers.models import ExternalCheckerRegistryEntryRecord + + assert model is ExternalCheckerRegistryEntryRecord and populate_existing is True + return next((row for row in self.rows if row.id == entry_id), None) def get_transaction(self): return self.root @@ -116,8 +123,10 @@ async def by_exact_id(self, entry_id): def _service(active: bool = True): authority = _Authority() - service = ExternalCheckerRegistryService(_Session(active), authority) # type: ignore[arg-type] + session = _Session(active) + service = ExternalCheckerRegistryService(session, authority) # type: ignore[arg-type] repository = _Repository() + session.rows = repository.rows service._repository = repository # type: ignore[assignment] return service, authority, repository diff --git a/backend/tests/checkers/external/test_runtime.py b/backend/tests/checkers/external/test_runtime.py index 6b8172a2e..0e4498b07 100644 --- a/backend/tests/checkers/external/test_runtime.py +++ b/backend/tests/checkers/external/test_runtime.py @@ -15,7 +15,14 @@ import pytest from pydantic import ValidationError -from app.adapters.checkers import external_checker_execution_factory +from app.adapters.checkers import ( + configured_external_checker_execution, + external_checker_execution_factory, +) +from app.adapters.checkers.external_service import ( + EXTERNAL_CHECKER_CAPABILITY_KEY, + EXTERNAL_CHECKER_PROVIDER_KEY, +) from app.core.config import Settings from app.interfaces.external_checker_execution import ( ExternalCheckerIsolationReceipt, @@ -44,7 +51,7 @@ ExternalCheckerContractError, ExternalCheckerExecutionRequest, ExternalCheckerExecutionResult, - ExternalCheckerMaterial, + PreSubmitExternalCheckerMaterial, external_checker_json_hash, make_external_checker_execution_request, make_external_checker_execution_result, @@ -60,20 +67,23 @@ async def _stream(value: bytes): def _request(*, identity=None, archive_sha256=SHA, archive_byte_count=4): + identity = identity or execution_identity() return make_external_checker_execution_request( registry=registry_entry(), - identity=identity or execution_identity(), + identity=identity, configuration={}, configuration_sha256=external_checker_json_hash({}), input={"task_version": "v3"}, input_sha256=external_checker_json_hash({"task_version": "v3"}), materials=( - ExternalCheckerMaterial( + PreSubmitExternalCheckerMaterial( role="submission_archive", - content_id=uuid4(), - replica_id=uuid4(), - sha256=archive_sha256, - byte_count=archive_byte_count, + prepared_generation_id=identity.prepared_generation_id, + attempt_id=identity.attempt_id, + attempt_request_digest=identity.attempt_request_digest, + archive_sha256=archive_sha256, + archive_byte_count=archive_byte_count, + semantic_manifest_sha256=SHA, media_type="application/zip", ), ), @@ -90,7 +100,7 @@ def test_shared_rust_fixtures_preserve_python_contract_and_derived_digests(): result.validate_request(request) assert request.configuration == {"threshold": 1e-6} assert request.request_digest == ( - "sha256:81adbc8a7cc0e13b4221ac06ecdd2f95850d6215c3a336d932ad14f8988a70b7" + "sha256:a06707b14348ffe5ef22a55de8d4ec4845c9029da7180d5731fb2c120b5591f3" ) changed = request.model_dump(mode="json") changed["configuration"]["threshold"] = 2e-6 @@ -136,6 +146,20 @@ def test_external_service_settings_share_the_art_scratch_root(tmp_path: Path): ) +@pytest.mark.asyncio +async def test_configured_execution_defers_absent_fixed_provider_until_invocation(): + adapter = configured_external_checker_execution(Settings()) + + assert adapter.identity.capability_key == EXTERNAL_CHECKER_CAPABILITY_KEY + assert adapter.identity.provider_key == EXTERNAL_CHECKER_PROVIDER_KEY + with pytest.raises(ExternalServiceUnavailableError) as invocation: + await adapter.execute(_request(), object()) + assert invocation.value.identity == adapter.identity + with pytest.raises(ExternalServiceUnavailableError) as health: + await adapter.health() + assert health.value.identity == adapter.identity + + def test_service_runtime_files_stay_outside_art_scratch_custody(tmp_path: Path): """A live or crashed service cannot poison ART's closed root layout.""" scratch = tmp_path / "scratch" diff --git a/backend/tests/checkers/external/test_selected_post_submit.py b/backend/tests/checkers/external/test_selected_post_submit.py new file mode 100644 index 000000000..5206b68ca --- /dev/null +++ b/backend/tests/checkers/external/test_selected_post_submit.py @@ -0,0 +1,519 @@ +"""Selected project POST images consume actual ART custody as evidence only.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import replace +import json +from types import SimpleNamespace + +import pytest + +from app.adapters.checkers import selected_submission_evaluation_content +from app.core.hashing import canonical_json_hash +from app.interfaces.external_checker_execution import ( + ExternalCheckerExecutionResponse, + ExternalCheckerIsolationReceipt, + ExternalCheckerMaterialGrant, +) +from app.modules.checkers.api.external import ( + POST_SUBMIT_INPUT_SCHEMA_ID, + ExternalCheckerRegistryEntry, + ExternalCheckerRegistrySpec, + ExternalCheckerSchema, + external_checker_json_hash, + make_external_checker_execution_result, +) +from app.modules.checkers.api.external_policy import ( + LockedExternalCheckerBinding, + LockedExternalPostSubmitPolicy, +) +from app.modules.checkers.api.evaluation_contract import ( + validate_execution_request, + validate_execution_result, + validate_stored_execution_request, + validate_stored_execution_result, +) +from app.modules.checkers.api.execution import EvaluationReservation, ExecutionLease +from app.modules.checkers.api.materialization import SubmissionMaterialEntry +from app.modules.checkers.selected_post_submit_execution import ( + SelectedPostSubmitProcessor, +) +from app.modules.checkers.api.selected_post_submit import ( + SelectedPostSubmitExecutionFacts, + SelectedPostSubmitLockedContext, + make_selected_post_submit_request, + make_selected_post_submit_result, +) +from app.core.identifiers import new_record_id +from tests.checkers.external.support import ( + SHA, + execution_identity, + execution_material, + post_registry_spec, + registry_entry, +) +from tests.checkers.execution.support import live_executor, reserve +from tests.post_submit_materialization_helpers import material_fixture + + +def _policy() -> LockedExternalPostSubmitPolicy: + values = registry_entry("post_submit").model_dump() + document = {"type": "object"} + values["input_schema"] = ExternalCheckerSchema( + schema_id=POST_SUBMIT_INPUT_SCHEMA_ID, + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ) + spec = { + key: value + for key, value in values.items() + if key + not in { + "registry_entry_id", + "entry_digest", + "registration_operation_id", + "registered_by_actor_profile_id", + "authorization_decision_event_id", + "created_at", + } + } + spec["input_schema"] = values["input_schema"].model_dump(mode="json") + values["entry_digest"] = external_checker_json_hash(spec) + binding = LockedExternalCheckerBinding( + requirement_id="project.post_evidence", + stage="post_submit", + registry=ExternalCheckerRegistryEntry.model_validate(values), + configuration={}, + configuration_sha256=external_checker_json_hash({}), + ) + body = { + "schema_version": "external_post_submit_policy.v1", + "entries": [binding.model_dump(mode="json")], + } + return LockedExternalPostSubmitPolicy( + entries=(binding,), + policy_sha256=canonical_json_hash(body), + ) + + +def _post_spec() -> ExternalCheckerRegistrySpec: + """Use the exact selected POST schema with the shared immutable registry facts.""" + return post_registry_spec() + + +def _request(identity, policy): + context = SelectedPostSubmitLockedContext( + guide_version="guide-v1", + source_id=new_record_id(), + source_hash=SHA, + effective_policy_id=new_record_id(), + effective_policy_hash=SHA, + pre_policy_id=new_record_id(), + pre_policy_hash=SHA, + post_policy_id=new_record_id(), + post_policy_version="guide-v1", + post_policy_hash=policy.policy_sha256, + review_policy_id=new_record_id(), + review_generation=1, + review_hash=SHA, + revision_policy_id=new_record_id(), + revision_generation=1, + revision_hash=SHA, + ) + return make_selected_post_submit_request( + project_id=identity.project_id, + expected_context=context, + policy=policy, + evaluation_request_id=identity.evaluation_request_id, + evaluation_generation=identity.evaluation_generation, + task_id=identity.task_id, + assignment_id=identity.assignment_id, + submission_id=identity.submission_id, + submission_version=identity.submission_version, + content_id=new_record_id(), + binding_id=new_record_id(), + content_sha256=SHA, + byte_count=42, + ) + + +class _MaterialView: + def __init__(self, material) -> None: + self.external_checker_material = material + self.entries = ( + SubmissionMaterialEntry("src/main.py", "file", 4, SHA, False), + ) + self.requests = [] + + @contextmanager + def external_checker_grant(self, request): + self.requests.append(request) + yield ExternalCheckerMaterialGrant( + grant_id="extract_" + "a" * 32, + binding_digest="sha256:" + "b" * 64, + ) + + +class _Execution: + def __init__(self, *, outcome="completed", verdict="passed") -> None: + self.outcome, self.verdict = outcome, verdict + self.requests = [] + + async def execute(self, request, _grant): + assert type(_grant) is ExternalCheckerMaterialGrant + self.requests.append(request) + infrastructure = self.outcome == "infrastructure_failed" + findings = () if self.verdict == "passed" or infrastructure else ({ + "code": "post_evidence_failed", + "level": "error", + "message": "Post evidence failed.", + },) + result = make_external_checker_execution_result( + request_digest=request.request_digest, + registry_entry_id=request.registry.registry_entry_id, + registry_entry_digest=request.registry.entry_digest, + phase="post_submit", + outcome=self.outcome, + verdict=None if infrastructure else self.verdict, + findings=findings, + infrastructure_failure_code="deadline_exceeded" if infrastructure else None, + ) + return ExternalCheckerExecutionResponse( + result=result, + isolation=None if infrastructure else ExternalCheckerIsolationReceipt( + repository="registry.example/workstream/checker", + platform_manifest_digest=request.registry.image_digest, + platform_manifest_media_type="application/vnd.oci.image.manifest.v1+json", + platform_manifest_byte_count=1024, + operating_system="linux", + architecture="amd64", + config_image_id=SHA, + runtime="runsc", + isolation_mode="gvisor", + sandbox_uid=65532, + sandbox_gid=65532, + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("verdict", ["passed", "failed"]) +async def test_selected_post_runs_every_locked_image_without_acceptance(verdict) -> None: + identity = execution_identity("post_submit") + material = execution_material(identity) + view, execution, policy = _MaterialView(material), _Execution(verdict=verdict), _policy() + result = await SelectedPostSubmitProcessor( + policy=policy, + identity=identity, + execution=execution, + ).evaluate(object(), view) + + assert len(result.external_members) == 1 + assert result.external_members[0].result.verdict == verdict + assert result.result_digest == canonical_json_hash(result.canonical_body()) + assert len(view.requests) == len(execution.requests) == 1 + assert view.requests[0].identity.submission_id == identity.submission_id + assert "routing" not in result.canonical_body() + assert "accepted" not in result.canonical_body() + + +@pytest.mark.asyncio +async def test_selected_post_separates_infrastructure_from_work_findings() -> None: + identity = execution_identity("post_submit") + with pytest.raises(RuntimeError, match="deadline_exceeded"): + await SelectedPostSubmitProcessor( + policy=_policy(), + identity=identity, + execution=_Execution(outcome="infrastructure_failed"), + ).evaluate(object(), _MaterialView(execution_material(identity))) + + +@pytest.mark.postgres_schema_contract +async def test_selected_post_materializes_actual_submission_and_replays( + tmp_path, isolated_database_env, +) -> None: + """Exercise selected POST through native Submission, ART, AUTH and storage.""" + async with material_fixture(tmp_path, isolated_database_env) as h: + reservation = await reserve(h) + executor = live_executor(h, selected_execution=_Execution()) + result = await executor.evaluate_post_submission(h.request) + assert result.outcome == "completed" + assert result.attempt_id == reservation.attempt_id + assert result.execution is not None + assert result.execution.external_members == () + assert await executor.evaluate_post_submission(h.request) == result + assert len(h.store.opens) == 1 + + +@pytest.mark.postgres_schema_contract +async def test_selected_post_persists_locked_external_member( + tmp_path, isolated_database_env, +) -> None: + """Compile, approve and execute one real registered POST requirement.""" + async with material_fixture( + tmp_path, + isolated_database_env, + post_submit_registry_spec=_post_spec(), + ) as h: + assert len(h.request.policy.entries) == 1 + execution = _Execution() + result = await live_executor( + h, + selected_execution=execution, + ).evaluate_post_submission(h.request) + assert result.outcome == "completed" + assert result.execution is not None + assert len(result.execution.external_members) == 1 + member = result.execution.external_members[0] + assert member.requirement_id == "project.post_evidence" + assert member.registry_entry_id == h.request.policy.entries[0].registry.registry_entry_id + assert member.result.verdict == "passed" + assert len(execution.requests) == 1 + + +@pytest.mark.asyncio +async def test_selected_post_request_and_result_bind_exact_locked_policy() -> None: + identity = execution_identity("post_submit") + policy = _policy() + request = _request(identity, policy) + execution = await SelectedPostSubmitProcessor( + policy=policy, + identity=identity, + execution=_Execution(), + ).evaluate(request, _MaterialView(execution_material(identity))) + result = make_selected_post_submit_result( + request_id=request.evaluation_request_id, + request_digest=request.request_sha256, + attempt_id=identity.attempt_id, + evaluation_generation=request.evaluation_generation, + result_id=identity.result_id, + outcome="completed", + execution=execution, + infrastructure_failure_code=None, + ) + + result.validate_request(request) + empty_policy = LockedExternalPostSubmitPolicy( + entries=(), + policy_sha256=canonical_json_hash( + { + "schema_version": "external_post_submit_policy.v1", + "entries": [], + } + ), + ) + with pytest.raises(ValueError, match="request mismatch"): + result.validate_request( + make_selected_post_submit_request( + **request.model_dump( + exclude={"request_sha256", "policy", "expected_context"}, + ), + policy=empty_policy, + expected_context=request.expected_context.model_copy( + update={ + "post_policy_hash": empty_policy.policy_sha256 + } + ), + ) + ) + + missing = SelectedPostSubmitExecutionFacts( + policy_sha256=policy.policy_sha256, + required_external_count=0, + external_members=(), + ) + with pytest.raises(ValueError, match="request mismatch"): + make_selected_post_submit_result( + **result.model_dump(exclude={"result_digest", "execution"}), + execution=missing, + ).validate_request(request) + + member = execution.external_members[0] + extra = SelectedPostSubmitExecutionFacts( + policy_sha256=policy.policy_sha256, + required_external_count=2, + external_members=( + member, + replace(member, order=1, requirement_id="project.unrequested"), + ), + ) + with pytest.raises(ValueError, match="request mismatch"): + make_selected_post_submit_result( + **result.model_dump(exclude={"result_digest", "execution"}), + execution=extra, + ).validate_request(request) + + second = policy.entries[0].model_copy( + update={"requirement_id": "project.post_evidence.secondary"} + ) + two_policy_body = { + "schema_version": "external_post_submit_policy.v1", + "entries": [item.model_dump(mode="json") for item in (*policy.entries, second)], + } + two_policy = LockedExternalPostSubmitPolicy( + entries=(*policy.entries, second), + policy_sha256=canonical_json_hash(two_policy_body), + ) + two_request = _request(identity, two_policy) + two_execution = await SelectedPostSubmitProcessor( + policy=two_policy, + identity=identity, + execution=_Execution(), + ).evaluate(two_request, _MaterialView(execution_material(identity))) + reordered = SelectedPostSubmitExecutionFacts( + policy_sha256=two_policy.policy_sha256, + required_external_count=2, + external_members=( + replace(two_execution.external_members[1], order=0), + replace(two_execution.external_members[0], order=1), + ), + ) + with pytest.raises(ValueError, match="request mismatch"): + make_selected_post_submit_result( + request_id=two_request.evaluation_request_id, + request_digest=two_request.request_sha256, + attempt_id=identity.attempt_id, + evaluation_generation=two_request.evaluation_generation, + result_id=identity.result_id, + outcome="completed", + execution=reordered, + infrastructure_failure_code=None, + ).validate_request(two_request) + + foreign_registry = two_policy.entries[0].registry.model_copy( + update={"registry_entry_id": new_record_id()} + ) + foreign_entry = two_policy.entries[0].model_copy(update={"registry": foreign_registry}) + foreign_policy_body = { + "schema_version": "external_post_submit_policy.v1", + "entries": [ + item.model_dump(mode="json") for item in (foreign_entry, two_policy.entries[1]) + ], + } + foreign_policy = LockedExternalPostSubmitPolicy( + entries=(foreign_entry, two_policy.entries[1]), + policy_sha256=canonical_json_hash(foreign_policy_body), + ) + foreign_request = _request(identity, foreign_policy) + wrong_registry_execution = SelectedPostSubmitExecutionFacts( + policy_sha256=foreign_policy.policy_sha256, + required_external_count=2, + external_members=two_execution.external_members, + ) + with pytest.raises(ValueError, match="request mismatch"): + make_selected_post_submit_result( + request_id=foreign_request.evaluation_request_id, + request_digest=foreign_request.request_sha256, + attempt_id=identity.attempt_id, + evaluation_generation=foreign_request.evaluation_generation, + result_id=identity.result_id, + outcome="completed", + execution=wrong_registry_execution, + infrastructure_failure_code=None, + ).validate_request(foreign_request) + + wrong_image_execution = SelectedPostSubmitExecutionFacts( + policy_sha256=two_policy.policy_sha256, + required_external_count=2, + external_members=( + replace( + two_execution.external_members[0], + isolation=replace( + two_execution.external_members[0].isolation, + platform_manifest_digest="sha256:" + "b" * 64, + ), + ), + two_execution.external_members[1], + ), + ) + with pytest.raises(ValueError, match="request mismatch"): + make_selected_post_submit_result( + request_id=two_request.evaluation_request_id, + request_digest=two_request.request_sha256, + attempt_id=identity.attempt_id, + evaluation_generation=two_request.evaluation_generation, + result_id=identity.result_id, + outcome="completed", + execution=wrong_image_execution, + infrastructure_failure_code=None, + ).validate_request(two_request) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "outcome,expected", + [("completed", "completed"), ("infrastructure_failed", "infrastructure_failed")], +) +async def test_selected_scheduler_consumer_derives_exact_envelope(outcome, expected) -> None: + from app.modules.checkers.execution import _SelectedExternalConsumer + + identity = execution_identity("post_submit") + policy = _policy() + request = _request(identity, policy) + lease = ExecutionLease( + reservation=EvaluationReservation( + request_id=request.evaluation_request_id, + request_digest=request.request_sha256, + attempt_id=identity.attempt_id, + result_id=identity.result_id, + evaluation_generation=request.evaluation_generation, + ), + lease_id=identity.lease_id, + lease_generation=identity.lease_generation, + expires_at=identity.lease_expires_at, + ) + result = await _SelectedExternalConsumer( + _Execution(outcome=outcome), lease + ).evaluate(request, _MaterialView(execution_material(identity))) + + assert result.outcome == expected + result.validate_request(request) + assert type(validate_execution_request(request.model_dump())) is type(request) + assert type(validate_execution_result(result.model_dump())) is type(result) + stored = json.loads(result.model_dump_json()) + assert type(validate_stored_execution_result(json.dumps(stored))) is type(result) + + +def test_execution_boundary_rejects_removed_catalogue_request_schema() -> None: + """Retained catalogue-shaped facts cannot enter the active scheduler.""" + removed = { + "schema_version": "post_submit_evaluation_request", + "evaluation_request_id": str(new_record_id()), + } + with pytest.raises(ValueError): + validate_execution_request(removed) + with pytest.raises(ValueError): + validate_stored_execution_request(json.dumps(removed)) + + +def test_selected_post_content_uses_exact_locked_external_policy() -> None: + policy = _policy() + locked = SimpleNamespace( + locked_guide_version="guide-v1", + locked_guide_source_snapshot_id=new_record_id(), + locked_guide_source_snapshot_hash=SHA, + locked_effective_project_submission_artifact_policy_id=new_record_id(), + locked_effective_project_submission_artifact_policy_hash=SHA, + locked_pre_submit_checker_policy_id=new_record_id(), + locked_pre_submit_checker_bundle_hash=SHA, + locked_post_submit_checker_policy_hash=policy.policy_sha256, + locked_post_submit_checker_policy_id=new_record_id(), + locked_post_submit_checker_policy_version="guide-v1", + locked_review_policy_id=new_record_id(), + locked_review_policy_generation=1, + locked_review_policy_hash=SHA, + locked_revision_policy_id=new_record_id(), + locked_revision_policy_generation=1, + locked_revision_policy_hash=SHA, + ) + task = SimpleNamespace( + locked_policy=locked, + locked_project_context=SimpleNamespace(project_id=new_record_id()), + ) + + content = selected_submission_evaluation_content(task, policy) + + assert content.expected_context.post_policy_hash == policy.policy_sha256 + assert content.expected_context.post_policy_hash == locked.locked_post_submit_checker_policy_hash diff --git a/backend/tests/checkers/external/test_selected_pre_submit.py b/backend/tests/checkers/external/test_selected_pre_submit.py new file mode 100644 index 000000000..b1848b7fd --- /dev/null +++ b/backend/tests/checkers/external/test_selected_pre_submit.py @@ -0,0 +1,963 @@ +"""Selected default and project PRE images share one verified ART callback.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from io import BytesIO +import json +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, Mock, patch +import zipfile +import pytest + +from app.adapters.checkers import configured_external_checker_execution +from app.core.config import Settings +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.interfaces.external_checker_execution import ( + ExternalCheckerExecutionResponse, + ExternalCheckerIsolationReceipt, + ExternalCheckerMaterialGrant, +) +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + WorkstreamDefaultCheckerInput, + workstream_default_input_sha256, +) +from app.modules.checkers.api.external import ( + PRE_SUBMIT_INPUT_SCHEMA_ID, + ExternalCheckerRegistryEntry, + ExternalCheckerSchema, + PreSubmitExternalCheckerIdentity, + PreSubmitExternalCheckerMaterial, + external_checker_json_hash, + make_external_checker_execution_result, +) +from app.modules.checkers.api.external_policy import ( + LockedExternalCheckerBinding, + LockedExternalCheckerPolicies, + LockedExternalPostSubmitPolicy, + LockedExternalPreSubmitPolicy, + LockedWorkstreamDefault, +) +from app.modules.checkers.api.materialization import SubmissionMaterialEntry +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitAdmissionLineage, + SelectedPreSubmitLeaseFacts, + SelectedPreSubmitInfrastructureUnavailable, + SelectedPreSubmitReservationFacts, + SelectedPreSubmitTerminalFacts, +) +from app.modules.checkers.selected_pre_submit_execution import SelectedPreSubmitProcessor +from app.adapters.checkers import SelectedPreSubmitCheckerExecutionAdapter +from app.modules.artifacts.preparation import ( + HARD_MAXIMUM_ARTIFACT_BYTES, + ArtifactPreparationLimits, + ArtifactPreparationService, + ArtifactScratchManager, +) +from app.modules.artifacts.selected_pre_submit_admission import ( + bind_selected_pre_submit_admission, + build_selected_pre_submit_reservation, +) +from app.modules.artifacts.submission_admission import SubmissionBundleAdmissionPublisher +from app.modules.artifacts.submission_archive import ( + SubmissionArchiveInspector, + SubmissionArchiveLimits, +) +from app.modules.artifacts.submission_manifest import ( + SubmissionCanonicalPredecessor, + build_submission_manifest, + semantic_manifest_identity, + evaluate_submission_change, +) +from app.modules.artifacts.submission_materialization import ( + PreparedBundleMaterializationRequest, + PreparedBundleMaterializationService, +) +from app.modules.authorization.prepared import PreparedAuthorizationHandle +from app.modules.checkers.api.pre_submit_material import SubmissionPacketView +from tests.checkers.external.support import SHA, registry_entry + + +async def _bytes(value: bytes): + yield value + + +def _archive(primary_bytes: bytes) -> bytes: + output = BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for name, value in ( + ("task.toml", primary_bytes), + ("evidence/results", b"verified\n"), + ): + entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + archive.writestr(entry, value) + return output.getvalue() + + +def _hex_sha(character: str) -> str: + return "sha256:" + character * 64 + + +def _selected_admission_reservation(): + manifest = {"schema_version": "submission_manifest.v1", "entries": []} + post_policy = {"schema_version": "external_post_submit_policy.v1", "entries": []} + post_policy_sha256 = canonical_json_hash(post_policy) + post_policy["policy_sha256"] = post_policy_sha256 + lineage = SelectedPreSubmitAdmissionLineage( + predecessor_submission_id=None, + predecessor_submission_version=None, + checker_remediation_manifest_id=None, + guide_id=new_record_id(), + guide_version="v2", + source_snapshot_id=new_record_id(), + source_snapshot_sha256=_hex_sha("1"), + locked_guide_sha256=_hex_sha("2"), + effective_policy_id=new_record_id(), + effective_policy_sha256=_hex_sha("3"), + pre_submit_policy_id=new_record_id(), + pre_submit_policy_sha256=_hex_sha("4"), + effective_plan_sha256=_hex_sha("5"), + rules_sha256=_hex_sha("8"), + external_checker_policies_sha256=_hex_sha("9"), + post_submit_policy_sha256=post_policy_sha256, + post_submit_policy_body=post_policy, + locked_policy_context_hash=_hex_sha("6"), + semantic_manifest_id=new_record_id(), + semantic_manifest_body=manifest, + packet_sha256=_hex_sha("7"), + storage_scheme="s3", + ) + selected = SelectedPreSubmitReservationFacts( + actor_profile_id=new_record_id(), + identity_link_id=new_record_id(), + project_id=new_record_id(), + task_id=new_record_id(), + assignment_id=new_record_id(), + attempt_id=new_record_id(), + idempotency_key=new_record_id(), + attempt_request_digest=_hex_sha("8"), + prepared_generation_id=new_record_id(), + policy_sha256=lineage.effective_plan_sha256, + default_behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + default_input_sha256=_hex_sha("9"), + archive_sha256=_hex_sha("a"), + archive_byte_count=25, + semantic_manifest_sha256=canonical_json_hash(manifest), + execution_budget_ms=120_000, + admission_lineage=lineage, + ) + return manifest, post_policy, selected + + +def _selected_admission_publisher_fixture(selected): + terminal = { + "request_digest": selected.request_digest, + "outcome": "completed", + "execution": {"eligible": True}, + } + attempt = SimpleNamespace( + id=str(new_record_id()), + producer_request_type="submission_bundle", + producer_ref=str(selected.actor_profile_id), + project_id=str(selected.project_id), + task_id=str(selected.task_id), + sha256=selected.archive_sha256, + byte_count=selected.archive_byte_count, + media_type="application/zip", + replica_id=str(new_record_id()), + receipt_id=str(new_record_id()), + ) + job = SimpleNamespace( + id=str(new_record_id()), + originating_put_attempt_id=attempt.id, + replica_id=attempt.replica_id, + execution_generation=2, + ) + intent = SimpleNamespace( + id=str(new_record_id()), + pre_submit_evidence_set_id=None, + selected_pre_submit_run_id=str(selected.attempt_id), + selected_request_json=selected.canonical_body(), + selected_request_digest=selected.request_digest, + selected_terminal_json=terminal, + selected_terminal_digest=canonical_json_hash(terminal), + ) + replica = SimpleNamespace( + id=attempt.replica_id, + content_id=str(new_record_id()), + verification_state="verified", + availability_state="available", + integrity_state="valid", + ) + content = SimpleNamespace( + id=replica.content_id, + sha256=attempt.sha256, + byte_count=attempt.byte_count, + ) + verification = SimpleNamespace( + id=str(new_record_id()), + verification_job_id=job.id, + execution_generation=job.execution_generation, + outcome="verified", + observed_sha256=attempt.sha256, + observed_byte_count=attempt.byte_count, + ) + session = SimpleNamespace( + scalar=AsyncMock( + side_effect=[ + job, + attempt, + intent, + None, + replica, + verification, + content, + SimpleNamespace(id=attempt.receipt_id), + datetime.now(UTC), + ] + ), + add=Mock(), + flush=AsyncMock(), + ) + return session, job, verification + + +def _limits() -> ArtifactPreparationLimits: + return ArtifactPreparationLimits( + aggregate_reserved_bytes=2 * HARD_MAXIMUM_ARTIFACT_BYTES, + maximum_files=2, + maximum_concurrency=2, + minimum_free_bytes=0, + reservation_ttl_seconds=30, + total_deadline_seconds=10, + cleanup_margin_seconds=5, + stream_buffer_bytes=1024, + maximum_source_bytes=1024 * 1024, + maximum_workspace_entries=2_000, + ) + + +class _PreparedHandle: + pass + + +async def _request( + tmp_path: Path, + *, + primary_bytes: bytes = b"[task]\nname='proof'\n", +): + inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) + manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) + preparation = ArtifactPreparationService(manager) + prepared = await preparation.prepare( + _bytes(_archive(primary_bytes)), media_type="application/zip" + ) + inspection = await prepared.inspect(inspector) + manifest = build_submission_manifest(inspection) + policy = _policy() + packet = SubmissionPacketView( + summary="Completed exact project work.", contributor_attestation="rights_confirmed", + ) + _, _, seed = _selected_admission_reservation() + default_input = WorkstreamDefaultCheckerInput( + archive_sha256=prepared.commitment.sha256, + archive_byte_count=prepared.commitment.byte_count, + archive_media_type="application/zip", + semantic_manifest_sha256=manifest.sha256, + entries=tuple(SubmissionMaterialEntry( + item.normalized_path, item.entry_type.value, item.byte_count, + item.sha256, item.executable, + ) for item in manifest.entries), + predecessor=None, predecessor_exists=False, current_predecessor=None, + current_rules_sha256=seed.admission_lineage.rules_sha256, + predecessor_rules_sha256=None, packet=packet, + ) + reservation = replace( + seed, + prepared_generation_id=prepared.generation_id, + policy_sha256=policy.policy_sha256, + default_input_sha256=workstream_default_input_sha256(default_input), + archive_sha256=prepared.commitment.sha256, + archive_byte_count=prepared.commitment.byte_count, + semantic_manifest_sha256=manifest.sha256, + admission_lineage=replace( + seed.admission_lineage, + effective_plan_sha256=policy.policy_sha256, + semantic_manifest_id=semantic_manifest_identity(manifest.sha256), + semantic_manifest_body=manifest.as_dict(), packet_sha256=packet.sha256, + ), + ) + request = PreparedBundleMaterializationRequest( + prepared_authorization=cast(PreparedAuthorizationHandle, _PreparedHandle()), + task_id=reservation.task_id, assignment_id=reservation.assignment_id, + submission_artifact_policy_id=reservation.admission_lineage.effective_policy_id, + checker_policy_id=reservation.admission_lineage.pre_submit_policy_id, + predecessor_submission_version=None, checker_remediation_manifest_id=None, + prepared_artifact=prepared, inspection=inspection, manifest=manifest, + change_gate=evaluate_submission_change( + commitment=prepared.commitment, manifest=manifest, + predecessor=None, predecessor_exists=False, + ), + packet=packet, external_policy=policy, default_checker_input=default_input, + selected_reservation=reservation, + ) + return request, inspector, manager, preparation + + +def _open_input_schema() -> ExternalCheckerSchema: + document = {"type": "object"} + return ExternalCheckerSchema( + schema_id=PRE_SUBMIT_INPUT_SCHEMA_ID, + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ) + + +def _policy() -> LockedExternalPreSubmitPolicy: + values = registry_entry().model_dump() + values["input_schema"] = _open_input_schema() + # The immutable entry digest includes the input schema. + spec = { + key: value + for key, value in values.items() + if key + not in { + "registry_entry_id", + "entry_digest", + "registration_operation_id", + "registered_by_actor_profile_id", + "authorization_decision_event_id", + "created_at", + } + } + spec["input_schema"] = values["input_schema"].model_dump(mode="json") + values["entry_digest"] = external_checker_json_hash(spec) + registry = ExternalCheckerRegistryEntry.model_validate(values) + binding = LockedExternalCheckerBinding( + requirement_id="project.archive_rules", + stage="pre_submit", + registry=registry, + configuration={}, + configuration_sha256=external_checker_json_hash({}), + ) + default = LockedWorkstreamDefault() + body = { + "schema_version": "external_pre_submit_policy.v1", + "workstream_default": default.model_dump(mode="json"), + "entries": [binding.model_dump(mode="json")], + } + return LockedExternalPreSubmitPolicy( + workstream_default=default, + entries=(binding,), + policy_sha256=canonical_json_hash(body), + ) + + +def _empty_policy() -> LockedExternalPreSubmitPolicy: + default = LockedWorkstreamDefault() + body = { + "schema_version": "external_pre_submit_policy.v1", + "workstream_default": default.model_dump(mode="json"), + "entries": [], + } + return LockedExternalPreSubmitPolicy( + workstream_default=default, + entries=(), + policy_sha256=canonical_json_hash(body), + ) + + +def _policies() -> LockedExternalCheckerPolicies: + pre = _policy() + post_body = { + "schema_version": "external_post_submit_policy.v1", + "entries": [], + } + post = LockedExternalPostSubmitPolicy( + entries=(), + policy_sha256=canonical_json_hash(post_body), + ) + body = { + "schema_version": "external_checker_policies.v1", + "pre_submit": pre.model_dump(mode="json"), + "post_submit": post.model_dump(mode="json"), + } + return LockedExternalCheckerPolicies( + pre_submit=pre, + post_submit=post, + policies_sha256=canonical_json_hash(body), + ) + + +class _GrantIssuer: + def __init__(self) -> None: + self.requests = [] + + @contextmanager + def issue(self, request, _workspace): + self.requests.append(request) + yield ExternalCheckerMaterialGrant( + grant_id="extract_" + "a" * 32, + binding_digest="sha256:" + "b" * 64, + ) + + +class _Execution: + def __init__(self, verdict: str) -> None: + self.verdict = verdict + self.requests = [] + + async def execute(self, request, _grant): + self.requests.append(request) + findings = ( + () + if self.verdict == "passed" + else ( + { + "code": "project_rule_failed", + "level": "error", + "message": "Project rule failed.", + }, + ) + ) + result = make_external_checker_execution_result( + request_digest=request.request_digest, + registry_entry_id=request.registry.registry_entry_id, + registry_entry_digest=request.registry.entry_digest, + phase="pre_submit", + outcome="completed", + verdict=self.verdict, + findings=findings, + infrastructure_failure_code=None, + ) + return ExternalCheckerExecutionResponse( + result=result, + isolation=ExternalCheckerIsolationReceipt( + repository="registry.example/workstream/checker", + platform_manifest_digest=request.registry.image_digest, + platform_manifest_media_type=("application/vnd.oci.image.manifest.v1+json"), + platform_manifest_byte_count=1024, + operating_system="linux", + architecture="amd64", + config_image_id=SHA, + runtime="runsc", + isolation_mode="gvisor", + sandbox_uid=65532, + sandbox_gid=65532, + ), + ) + + +def _execution_context(request, policy): + identity = PreSubmitExternalCheckerIdentity( + project_id=request.selected_reservation.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + prepared_generation_id=request.prepared_artifact.generation_id, + attempt_id=new_record_id(), + attempt_request_digest=SHA, + effective_plan_sha256=policy.policy_sha256, + ) + material = PreSubmitExternalCheckerMaterial( + role="submission_archive", + prepared_generation_id=identity.prepared_generation_id, + attempt_id=identity.attempt_id, + attempt_request_digest=identity.attempt_request_digest, + archive_sha256=request.prepared_artifact.commitment.sha256, + archive_byte_count=request.prepared_artifact.commitment.byte_count, + semantic_manifest_sha256=request.manifest.sha256, + media_type="application/zip", + ) + entries = tuple( + SubmissionMaterialEntry( + item.normalized_path, + item.entry_type.value, + item.byte_count, + item.sha256, + item.executable, + ) + for item in request.manifest.entries + ) + default_input = WorkstreamDefaultCheckerInput( + archive_sha256=material.archive_sha256, + archive_byte_count=material.archive_byte_count, + archive_media_type=material.media_type, + semantic_manifest_sha256=material.semantic_manifest_sha256, + entries=entries, + predecessor=None, + predecessor_exists=False, + current_predecessor=None, + current_rules_sha256=request.selected_reservation.admission_lineage.rules_sha256, + predecessor_rules_sha256=None, + packet=SubmissionPacketView(summary="", contributor_attestation=""), + ) + return identity, material, default_input + + +def _assert_terminal_round_trip(identity, policy, default_input, material, result) -> None: + now = datetime.now(UTC) + reservation = SelectedPreSubmitReservationFacts( + actor_profile_id=new_record_id(), + identity_link_id=new_record_id(), + project_id=identity.project_id, + task_id=identity.task_id, + assignment_id=identity.assignment_id, + attempt_id=identity.attempt_id, + idempotency_key=new_record_id(), + attempt_request_digest=identity.attempt_request_digest, + prepared_generation_id=identity.prepared_generation_id, + policy_sha256=policy.policy_sha256, + default_behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + default_input_sha256=workstream_default_input_sha256(default_input), + archive_sha256=material.archive_sha256, + archive_byte_count=material.archive_byte_count, + semantic_manifest_sha256=material.semantic_manifest_sha256, + execution_budget_ms=120_000, + ) + lease = SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=new_record_id(), + lease_generation=1, + lease_expires_at=now + timedelta(minutes=2), + ) + terminal = SelectedPreSubmitTerminalFacts( + lease=lease, + outcome="completed", + execution=result, + infrastructure_failure_code=None, + queued_at=now, + started_at=now + timedelta(milliseconds=25), + completed_at=now + timedelta(milliseconds=75), + ) + assert terminal.canonical_body()["queue_latency_ms"] == 25 + assert terminal.canonical_body()["execution_latency_ms"] == 50 + assert terminal.canonical_body()["request_digest"] == reservation.request_digest + assert terminal.terminal_digest == canonical_json_hash(terminal.canonical_body()) + assert SelectedPreSubmitTerminalFacts.from_canonical_body(terminal.canonical_body()) == terminal + changed_terminal = terminal.canonical_body() | {"queue_latency_ms": 26} + with pytest.raises(ValueError, match="terminal facts"): + SelectedPreSubmitTerminalFacts.from_canonical_body(changed_terminal) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("verdict,eligible", [("passed", True), ("failed", False)]) +async def test_selected_pre_runs_default_then_locked_external_image( + tmp_path, verdict: str, eligible: bool +) -> None: + request, inspector, manager, preparation = await _request(tmp_path) + policy = _policy() + identity, material, default_input = _execution_context(request, policy) + grants, execution = _GrantIssuer(), _Execution(verdict) + processor = SelectedPreSubmitProcessor( + archive_inspector=inspector, + inspection=request.inspection, + policy=policy, + default_input=default_input, + identity=identity, + material=material, + grants=grants, + execution=execution, + ) + try: + result = await preparation._process_prepared_submission( + request.prepared_artifact, + processor, + reserved_bytes=request.manifest.total_expanded_bytes, + maximum_entries=request.manifest.entry_count, + ) + assert result.eligible is eligible + assert result.workstream_default.eligible + assert [item.code for item in result.workstream_default.warnings] == [ + "submission_summary_missing", + "submission_attestation_missing", + ] + assert len(result.external_members) == 1 + assert result.external_members[0].result.verdict == verdict + assert len(grants.requests) == len(execution.requests) == 1 + assert grants.requests[0].request_digest == execution.requests[0].request_digest + assert result.result_digest == canonical_json_hash(result.canonical_body()) + _assert_terminal_round_trip(identity, policy, default_input, material, result) + finally: + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("external", [False, True]) +async def test_selected_pre_only_requires_runtime_for_locked_external_images( + tmp_path, external: bool +) -> None: + request, inspector, manager, preparation = await _request(tmp_path) + policy = _policy() if external else _empty_policy() + identity, material, default_input = _execution_context(request, policy) + grants = _GrantIssuer() + processor = SelectedPreSubmitProcessor( + archive_inspector=inspector, + inspection=request.inspection, + policy=policy, + default_input=default_input, + identity=identity, + material=material, + grants=grants, + execution=configured_external_checker_execution(Settings()), + ) + try: + if external: + with pytest.raises(SelectedPreSubmitInfrastructureUnavailable) as failure: + await preparation._process_prepared_submission( + request.prepared_artifact, + processor, + reserved_bytes=request.manifest.total_expanded_bytes, + maximum_entries=request.manifest.entry_count, + ) + assert failure.value.code == "implementation_unavailable" + assert len(grants.requests) == 1 + else: + result = await preparation._process_prepared_submission( + request.prepared_artifact, + processor, + reserved_bytes=request.manifest.total_expanded_bytes, + maximum_entries=request.manifest.entry_count, + ) + assert result.eligible is True + assert result.required_external_count == 0 + assert result.external_members == () + assert grants.requests == [] + finally: + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("replacement", [False, True]) +async def test_selected_pre_terminal_publishes_self_contained_ready_admission( + replacement: bool, +) -> None: + manifest, post_policy, selected = _selected_admission_reservation() + if replacement: + selected = replace( + selected, + admission_lineage=replace( + selected.admission_lineage, + predecessor_submission_id=new_record_id(), + predecessor_submission_version=1, + checker_remediation_manifest_id=new_record_id(), + ), + ) + lineage = selected.admission_lineage + assert ( + SelectedPreSubmitAdmissionLineage.from_canonical_body(lineage.canonical_body()) == lineage + ) + session, job, verification = _selected_admission_publisher_fixture(selected) + + admission = await SubmissionBundleAdmissionPublisher(session).publish_verified( + verification_job_id=job.id, + verification_receipt_id=verification.id, + ) + + assert admission.checker_remediation_manifest_id == ( + str(lineage.checker_remediation_manifest_id) if replacement else None + ) + assert admission.pre_submit_evidence_set_id is None + assert admission.selected_pre_submit_run_id == str(selected.attempt_id) + assert admission.packet_sha256 == selected.admission_lineage.packet_sha256 + assert admission.semantic_manifest_body == manifest + assert ( + admission.selected_post_submit_policy_sha256 + == selected.admission_lineage.post_submit_policy_sha256 + ) + assert admission.selected_post_submit_policy_body == post_policy + + +@pytest.mark.asyncio +async def test_selected_pre_default_secret_blocker_precedes_external_image( + tmp_path, +) -> None: + request, inspector, manager, preparation = await _request( + tmp_path, + primary_bytes=b"-----BEGIN OPENSSH PRIVATE KEY-----\nencoded", + ) + policy = _policy() + identity = PreSubmitExternalCheckerIdentity( + project_id=request.selected_reservation.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + prepared_generation_id=request.prepared_artifact.generation_id, + attempt_id=new_record_id(), + attempt_request_digest=SHA, + effective_plan_sha256=policy.policy_sha256, + ) + material = PreSubmitExternalCheckerMaterial( + role="submission_archive", + prepared_generation_id=identity.prepared_generation_id, + attempt_id=identity.attempt_id, + attempt_request_digest=identity.attempt_request_digest, + archive_sha256=request.prepared_artifact.commitment.sha256, + archive_byte_count=request.prepared_artifact.commitment.byte_count, + semantic_manifest_sha256=request.manifest.sha256, + media_type="application/zip", + ) + entries = tuple( + SubmissionMaterialEntry( + item.normalized_path, + item.entry_type.value, + item.byte_count, + item.sha256, + item.executable, + ) + for item in request.manifest.entries + ) + execution = _Execution("passed") + grants = _GrantIssuer() + processor = SelectedPreSubmitProcessor( + archive_inspector=inspector, + inspection=request.inspection, + policy=policy, + default_input=WorkstreamDefaultCheckerInput( + archive_sha256=material.archive_sha256, + archive_byte_count=material.archive_byte_count, + archive_media_type=material.media_type, + semantic_manifest_sha256=material.semantic_manifest_sha256, + entries=entries, + predecessor=None, + predecessor_exists=False, + current_predecessor=None, + current_rules_sha256=request.selected_reservation.admission_lineage.rules_sha256, + predecessor_rules_sha256=None, + packet=request.packet, + ), + identity=identity, + material=material, + grants=grants, + execution=execution, + ) + try: + result = await preparation._process_prepared_submission( + request.prepared_artifact, + processor, + reserved_bytes=request.manifest.total_expanded_bytes, + maximum_entries=request.manifest.entry_count, + ) + assert result.eligible is False + assert result.workstream_default.checks[2].failure_code == ( + "high_confidence_secret_detected" + ) + assert result.external_members == () + assert grants.requests == [] + assert execution.requests == [] + finally: + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_selected_admission_binds_unchanged_work_to_current_task_rules( + tmp_path, +) -> None: + request, _inspector, manager, _preparation = await _request(tmp_path) + policies = _policies() + project_id = request.selected_reservation.project_id + actor_profile_id = new_record_id() + identity_link_id = new_record_id() + assignment_id = new_record_id() + guide_id = new_record_id() + source_snapshot_id = new_record_id() + effective_policy_id = new_record_id() + pre_submit_policy_id = new_record_id() + predecessor = SubmissionCanonicalPredecessor( + submission_id=new_record_id(), + submission_version=1, + archive_sha256=request.prepared_artifact.commitment.sha256, + semantic_manifest_sha256=request.manifest.sha256, + ) + task = SimpleNamespace( + contributor_id=actor_profile_id, + task_id=request.task_id, + assignment_id=assignment_id, + checker_remediation_manifest_id=new_record_id(), + predecessor=SimpleNamespace( + submission_id=predecessor.submission_id, + version=predecessor.submission_version, + ), + locked_project_context=SimpleNamespace(project_id=project_id), + locked_policy=SimpleNamespace( + model_dump=lambda **_kwargs: { + "locked_guide_version": "v1", + "locked_pre_submit_checker_bundle_hash": SHA, + } + ), + ) + project = SimpleNamespace( + project_id=project_id, + guide_id=guide_id, + guide_version="v1", + source_snapshot_id=source_snapshot_id, + source_snapshot_hash=SHA, + effective_policy_id=effective_policy_id, + effective_policy_hash=SHA, + pre_submit_policy_id=pre_submit_policy_id, + pre_submit_policy_bundle_hash=SHA, + external_checker_policies=SimpleNamespace( + value=json.dumps( + policies.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ) + ), + ) + try: + bound = bind_selected_pre_submit_admission( + task=task, + project=project, + commitment=request.prepared_artifact.commitment, + manifest=request.manifest, + predecessor=predecessor, + predecessor_rules_sha256="sha256:" + "d" * 64, + predecessor_exists=True, + packet=request.packet, + ) + assert bound.change_gate.outcome.value == "unchanged" + assert bound.default_input.predecessor is not None + assert bound.default_input.predecessor_rules_sha256 == "sha256:" + "d" * 64 + assert bound.default_input.current_rules_sha256 == bound.rules_sha256 + assert bound.policy.policy_sha256 == policies.pre_submit.policy_sha256 + reservation = build_selected_pre_submit_reservation( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + task=task, + project=project, + prepared_generation_id=request.prepared_artifact.generation_id, + attempt_id=new_record_id(), + idempotency_key=new_record_id(), + attempt_request_digest=SHA, + commitment=request.prepared_artifact.commitment, + manifest=request.manifest, + packet=request.packet, + selected=bound, + storage_scheme="s3", + execution_budget_ms=120_000, + ) + assert reservation.admission_lineage is not None + assert reservation.admission_lineage.semantic_manifest_body == (request.manifest.as_dict()) + assert reservation.admission_lineage.packet_sha256 == request.packet.sha256 + assert reservation.admission_lineage.checker_remediation_manifest_id == ( + task.checker_remediation_manifest_id + ) + assert reservation.admission_lineage.predecessor_submission_id == ( + predecessor.submission_id + ) + finally: + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_selected_factory_uses_request_bound_art_grant(tmp_path) -> None: + request, inspector, manager, preparation = await _request(tmp_path) + policy = request.external_policy + entries = tuple( + SubmissionMaterialEntry( + item.normalized_path, + item.entry_type.value, + item.byte_count, + item.sha256, + item.executable, + ) + for item in request.manifest.entries + ) + request = replace( + request, external_policy=policy, + default_checker_input=WorkstreamDefaultCheckerInput( + archive_sha256=request.prepared_artifact.commitment.sha256, + archive_byte_count=request.prepared_artifact.commitment.byte_count, + archive_media_type="application/zip", + semantic_manifest_sha256=request.manifest.sha256, + entries=entries, predecessor=None, predecessor_exists=False, + current_predecessor=None, + current_rules_sha256=request.selected_reservation.admission_lineage.rules_sha256, + predecessor_rules_sha256=None, packet=request.packet, + ), + ) + reservation = request.selected_reservation + lease = SelectedPreSubmitLeaseFacts( + reservation=reservation, lease_id=new_record_id(), lease_generation=1, + lease_expires_at=datetime.now(UTC) + timedelta(minutes=2), + ) + transaction = True + session = SimpleNamespace(in_transaction=lambda: transaction) + + class CommittedAuthority: + def __init__(self): + self.calls = 0 + + def consume(self, *, run_id, lease): + assert not transaction + assert run_id == reservation.attempt_id + assert lease == expected_lease + assert self.calls == 0 + self.calls += 1 + return lease + + expected_lease = lease + committed = CommittedAuthority() + execution = _Execution("passed") + authority = SimpleNamespace( + prepare=AsyncMock(return_value=object()), + consume=AsyncMock(), + ) + service = PreparedBundleMaterializationService( + session=session, + authorization=authority, + preparation=preparation, + checker_execution=SelectedPreSubmitCheckerExecutionAdapter( + archive_inspector=inspector, + external_execution=execution, + ), + storage_scheme="s3", + ) + try: + await service.authorize_inspected(request) + assert authority.consume.await_count == 1 + assert execution.requests == [] + transaction = False + # A substituted default packet must fail before byte access or grants, + # even when the committed lease is otherwise exact. + process = AsyncMock() + with patch.object(preparation, "_process_prepared_submission", process): + with pytest.raises(SelectedPreSubmitInfrastructureUnavailable) as refusal: + await service.materialize_selected_pre_submit( + replace( + request, + prepared_authorization=None, + default_checker_input=replace( + request.default_checker_input, + packet=SubmissionPacketView("Changed packet", "rights_confirmed"), + ), + ), + run_id=reservation.attempt_id, lease=lease, + execution_authority=CommittedAuthority(), + ) + assert refusal.value.code == "material_unavailable" + process.assert_not_awaited() + assert execution.requests == [] + result = await service.materialize_selected_pre_submit( + replace(request, prepared_authorization=None), + run_id=reservation.attempt_id, lease=lease, execution_authority=committed, + ) + assert result.eligible is True + assert committed.calls == 1 + assert len(execution.requests) == 1 + assert execution.requests[0].identity.attempt_id == reservation.attempt_id + assert execution.requests[0].materials[0].archive_sha256 == ( + request.prepared_artifact.commitment.sha256 + ) + finally: + await request.prepared_artifact.close() + manager.close() diff --git a/backend/tests/checkers/external/test_selected_pre_submit_coordination.py b/backend/tests/checkers/external/test_selected_pre_submit_coordination.py new file mode 100644 index 000000000..cc5cbff4a --- /dev/null +++ b/backend/tests/checkers/external/test_selected_pre_submit_coordination.py @@ -0,0 +1,486 @@ +"""Selected PRE durable ownership stays exact before native migration proof.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from sqlalchemy.dialects import postgresql +from sqlalchemy.schema import CreateTable + +from app.core.identifiers import new_record_id +from app.core.hashing import canonical_json_hash +from app.modules.artifacts.schemas import SubmissionBundleArtifactAdmissionRequest +from app.modules.artifacts.submission_bindings import SubmissionAdmissionConsumptionService +from app.modules.artifacts.models import ( + SubmissionBundleAdmission, + SubmissionBundleDurableIntent, +) +from app.modules.checkers.api.default_checker import WORKSTREAM_DEFAULT_BEHAVIOR_SHA256 +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitExecuteFacts, + SelectedPreSubmitAdmissionLineage, + SelectedPreSubmitFinalizeFacts, + SelectedPreSubmitLeaseFacts, + SelectedPreSubmitReservationFacts, + SelectedPreSubmitRequestConflict, + SelectedPreSubmitUnavailable, +) +from app.adapters.tasks import selected_pre_submit_task_guard +from app.modules.tasks.submission_context import TaskSubmissionContextService +from app.modules.checkers.models import SelectedPreSubmitRun +from app.modules.checkers.selected_pre_submit_coordination import ( + SelectedPreSubmitAdmissionCapability, + SelectedPreSubmitExecutionCapability, + SelectedPreSubmitCoordinator, +) +from tests.checkers.external.support import SHA + + +_POST_POLICY_WITHOUT_DIGEST = { + "schema_version": "external_post_submit_policy.v1", + "entries": [], +} +_POST_POLICY_SHA256 = canonical_json_hash(_POST_POLICY_WITHOUT_DIGEST) +_POST_POLICY = _POST_POLICY_WITHOUT_DIGEST | {"policy_sha256": _POST_POLICY_SHA256} + + +def _reservation() -> SelectedPreSubmitReservationFacts: + return SelectedPreSubmitReservationFacts( + actor_profile_id=new_record_id(), + identity_link_id=new_record_id(), + project_id=new_record_id(), + task_id=new_record_id(), + assignment_id=new_record_id(), + attempt_id=new_record_id(), + idempotency_key=new_record_id(), + attempt_request_digest=SHA, + prepared_generation_id=new_record_id(), + policy_sha256="sha256:" + "1" * 64, + default_behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + default_input_sha256="sha256:" + "2" * 64, + archive_sha256="sha256:" + "3" * 64, + archive_byte_count=1024, + semantic_manifest_sha256="sha256:" + "4" * 64, + execution_budget_ms=120_000, + ) + + +def _queued_row(reservation: SelectedPreSubmitReservationFacts) -> SimpleNamespace: + return SimpleNamespace( + id=str(reservation.attempt_id), + actor_profile_id=str(reservation.actor_profile_id), + identity_link_id=str(reservation.identity_link_id), + project_id=str(reservation.project_id), + task_id=str(reservation.task_id), + assignment_id=str(reservation.assignment_id), + idempotency_key=str(reservation.idempotency_key), + prepared_generation_id=str(reservation.prepared_generation_id), + request_json=reservation.canonical_body(), + request_digest=reservation.request_digest, + policy_sha256=reservation.policy_sha256, + default_behavior_sha256=reservation.default_behavior_sha256, + default_input_sha256=reservation.default_input_sha256, + archive_sha256=reservation.archive_sha256, + archive_byte_count=reservation.archive_byte_count, + semantic_manifest_sha256=reservation.semantic_manifest_sha256, + execution_budget_ms=reservation.execution_budget_ms, + status="queued", + terminal_json=None, + terminal_digest=None, + eligible=None, + ) + + +@pytest.mark.asyncio +async def test_selected_pre_current_replays_only_stable_actor_key_facts() -> None: + stored = _reservation() + row = _queued_row(stored) + transaction = SimpleNamespace(is_active=True) + session = SimpleNamespace( + sync_session=SimpleNamespace(get_transaction=lambda: transaction), + in_nested_transaction=lambda: False, + scalar=AsyncMock(return_value=row), + ) + tasks = SimpleNamespace(lock_selected_pre_submit_scope=AsyncMock()) + coordinator = SelectedPreSubmitCoordinator(session, tasks=tasks) + fresh = replace( + stored, + attempt_id=new_record_id(), + prepared_generation_id=new_record_id(), + ) + + current = await coordinator.read_current(fresh) + + assert current is not None + assert current.run_id == stored.attempt_id + assert current.reservation == stored + assert current.status == "queued" + tasks.lock_selected_pre_submit_scope.assert_awaited_once_with(fresh) + + with pytest.raises( + SelectedPreSubmitRequestConflict, + match="selected_pre_submit_request_conflict", + ): + await coordinator.read_current(replace(fresh, archive_sha256="sha256:" + "f" * 64)) + + +@pytest.mark.asyncio +async def test_selected_pre_expired_lease_read_keeps_task_first_custody() -> None: + reservation = _reservation() + row = _queued_row(reservation) + row.status = "running" + row.lease_id = str(new_record_id()) + row.lease_generation = 3 + row.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + now = datetime.now(UTC) + transaction = SimpleNamespace(is_active=True) + session = SimpleNamespace( + sync_session=SimpleNamespace(get_transaction=lambda: transaction), + in_nested_transaction=lambda: False, + scalar=AsyncMock(side_effect=[row, now]), + ) + tasks = SimpleNamespace(lock_selected_pre_submit_scope=AsyncMock()) + coordinator = SelectedPreSubmitCoordinator(session, tasks=tasks) + + lease = await coordinator.read_expired_lease( + run_id=reservation.attempt_id, + facts=reservation, + ) + + assert lease.reservation == reservation + assert lease.lease_id == UUID(row.lease_id) + assert lease.lease_generation == 3 + assert lease.lease_expires_at == row.lease_expires_at + tasks.lock_selected_pre_submit_scope.assert_awaited_once_with(reservation) + + +@pytest.mark.asyncio +async def test_selected_pre_task_guard_rechecks_exact_locked_lineage(monkeypatch) -> None: + lineage = SelectedPreSubmitAdmissionLineage( + predecessor_submission_id=new_record_id(), + predecessor_submission_version=2, + checker_remediation_manifest_id=new_record_id(), + guide_id=new_record_id(), + guide_version="guide-v2", + source_snapshot_id=new_record_id(), + source_snapshot_sha256="sha256:" + "5" * 64, + locked_guide_sha256="sha256:" + "6" * 64, + effective_policy_id=new_record_id(), + effective_policy_sha256="sha256:" + "7" * 64, + pre_submit_policy_id=new_record_id(), + pre_submit_policy_sha256="sha256:" + "8" * 64, + effective_plan_sha256="sha256:" + "9" * 64, + rules_sha256="sha256:" + "d" * 64, + external_checker_policies_sha256=SHA, + post_submit_policy_sha256=_POST_POLICY_SHA256, + post_submit_policy_body=_POST_POLICY, + locked_policy_context_hash="sha256:" + "a" * 64, + semantic_manifest_id=new_record_id(), + semantic_manifest_body={"schema_version": "test"}, + packet_sha256="sha256:" + "b" * 64, + storage_scheme="s3", + ) + reservation = replace( + _reservation(), + policy_sha256=lineage.effective_plan_sha256, + semantic_manifest_sha256=canonical_json_hash(lineage.semantic_manifest_body), + admission_lineage=lineage, + ) + locked = SimpleNamespace( + project_id=reservation.project_id, + guide_version=lineage.guide_version, + source_snapshot_id=lineage.source_snapshot_id, + source_snapshot_hash=lineage.source_snapshot_sha256, + effective_policy_id=lineage.effective_policy_id, + effective_policy_hash=lineage.effective_policy_sha256, + pre_submit_policy_id=lineage.pre_submit_policy_id, + pre_submit_policy_bundle_hash=lineage.pre_submit_policy_sha256, + ) + context = SimpleNamespace( + task_id=reservation.task_id, + assignment_id=reservation.assignment_id, + contributor_id=reservation.actor_profile_id, + checker_remediation_manifest_id=(lineage.checker_remediation_manifest_id), + predecessor=SimpleNamespace( + submission_id=lineage.predecessor_submission_id, + version=lineage.predecessor_submission_version, + ), + locked_project_context=locked, + ) + + async def lock(_repository, request): + assert request.task_id == reservation.task_id + return context + + monkeypatch.setattr(TaskSubmissionContextService, "lock_submission_context", lock) + guard = selected_pre_submit_task_guard(SimpleNamespace()) + await guard.lock_selected_pre_submit_scope(reservation) + + context.checker_remediation_manifest_id = new_record_id() + with pytest.raises(RuntimeError, match="selected PRE TASK custody changed"): + await guard.lock_selected_pre_submit_scope(reservation) + context.checker_remediation_manifest_id = lineage.checker_remediation_manifest_id + + locked.pre_submit_policy_bundle_hash = "sha256:" + "c" * 64 + with pytest.raises(RuntimeError, match="selected PRE TASK custody changed"): + await guard.lock_selected_pre_submit_scope(reservation) + + +def test_selected_pre_model_compiles_complete_owner_and_authority_custody() -> None: + sql = str(CreateTable(SelectedPreSubmitRun.__table__).compile(dialect=postgresql.dialect())) + for value in ( + "fk_selected_pre_run_task_project", + "fk_selected_pre_run_assignment", + "execute_evidence_id", + "finalize_evidence_id", + "execution_budget_ms", + ): + assert value in sql + assert "pre_submit_execution_attempts" not in sql + + intent_sql = str( + CreateTable(SubmissionBundleDurableIntent.__table__).compile(dialect=postgresql.dialect()) + ) + admission_sql = str( + CreateTable(SubmissionBundleAdmission.__table__).compile(dialect=postgresql.dialect()) + ) + for compiled in (intent_sql, admission_sql): + assert "selected_pre_submit_run_id" in compiled + assert "pre_submit_lineage" in compiled + for value in ( + "semantic_manifest_body", + "packet_sha256", + "effective_plan_sha256", + "pre_submit_policy_sha256", + ): + assert value in admission_sql + + +def test_selected_pre_admission_capability_is_exact_and_single_use() -> None: + reservation = _reservation() + owner = SimpleNamespace(_capability=None) + run_id = new_record_id() + terminal = SimpleNamespace( + lease=SimpleNamespace(reservation=reservation), + terminal_digest="sha256:" + "5" * 64, + ) + capability = SelectedPreSubmitAdmissionCapability._issued( + owner=owner, + run_id=run_id, + terminal=terminal, + ) + owner._capability = capability + values = { + "run_id": run_id, + "terminal_digest": terminal.terminal_digest, + "attempt_id": reservation.attempt_id, + "prepared_generation_id": reservation.prepared_generation_id, + "archive_sha256": reservation.archive_sha256, + "semantic_manifest_sha256": reservation.semantic_manifest_sha256, + } + with pytest.raises( + SelectedPreSubmitUnavailable, match="selected_pre_submit_admission_unavailable" + ): + capability.consume(**(values | {"semantic_manifest_sha256": "sha256:" + "6" * 64})) + assert capability.consume(**values) == run_id + with pytest.raises(SelectedPreSubmitUnavailable): + capability.consume(**values) + + +def test_selected_pre_execution_capability_is_exact_and_single_use() -> None: + from datetime import UTC, datetime + + reservation = _reservation() + lease = SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=new_record_id(), + lease_generation=1, + lease_expires_at=datetime.now(UTC), + ) + owner = SimpleNamespace(_execution_capability=None) + capability = SelectedPreSubmitExecutionCapability._issued( + owner=owner, + run_id=reservation.attempt_id, + lease=lease, + ) + owner._execution_capability = capability + with pytest.raises( + SelectedPreSubmitUnavailable, match="selected_pre_submit_execution_unavailable" + ): + capability.consume(run_id=new_record_id(), lease=lease) + assert ( + capability.consume( + run_id=reservation.attempt_id, + lease=lease, + ) + == lease + ) + with pytest.raises(SelectedPreSubmitUnavailable): + capability.consume(run_id=reservation.attempt_id, lease=lease) + + +def test_selected_pre_authority_digests_bind_lease_and_infrastructure_outcome() -> None: + from datetime import UTC, datetime, timedelta + + reservation = _reservation() + lease = SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=new_record_id(), + lease_generation=1, + lease_expires_at=datetime.now(UTC), + ) + execution = SelectedPreSubmitExecuteFacts(lease) + changed = SelectedPreSubmitExecuteFacts( + SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=lease.lease_id, + lease_generation=2, + lease_expires_at=lease.lease_expires_at + timedelta(seconds=1), + ) + ) + terminal = SelectedPreSubmitFinalizeFacts( + lease=lease, + execution=None, + infrastructure_failure_code="deadline_exceeded", + ) + + assert execution.facts_digest != changed.facts_digest + assert ( + terminal.facts_digest + != SelectedPreSubmitFinalizeFacts( + lease=lease, + execution=None, + infrastructure_failure_code="material_unavailable", + ).facts_digest + ) + with pytest.raises(ValueError, match="outcome"): + SelectedPreSubmitFinalizeFacts( + lease=lease, + execution=None, + infrastructure_failure_code=None, + ) + + +def test_selected_pre_request_binds_complete_later_admission_lineage() -> None: + manifest = {"schema_version": "submission_manifest.v1", "entries": []} + digest = canonical_json_hash(manifest) + lineage = SelectedPreSubmitAdmissionLineage( + predecessor_submission_id=None, + predecessor_submission_version=None, + checker_remediation_manifest_id=None, + guide_id=new_record_id(), + guide_version="locked-v1", + source_snapshot_id=new_record_id(), + source_snapshot_sha256=SHA, + locked_guide_sha256=SHA, + effective_policy_id=new_record_id(), + effective_policy_sha256=SHA, + pre_submit_policy_id=new_record_id(), + pre_submit_policy_sha256=SHA, + effective_plan_sha256="sha256:" + "1" * 64, + rules_sha256="sha256:" + "2" * 64, + external_checker_policies_sha256=SHA, + post_submit_policy_sha256=_POST_POLICY_SHA256, + post_submit_policy_body=_POST_POLICY, + locked_policy_context_hash=SHA, + semantic_manifest_id=new_record_id(), + semantic_manifest_body=manifest, + packet_sha256=SHA, + storage_scheme="s3", + ) + reservation = replace( + _reservation(), + policy_sha256=lineage.effective_plan_sha256, + semantic_manifest_sha256=digest, + admission_lineage=lineage, + ) + + assert reservation.canonical_body()["admission_lineage"] == lineage.canonical_body() + assert ( + SelectedPreSubmitReservationFacts.from_canonical_body(reservation.canonical_body()) + == reservation + ) + assert ( + reservation.request_digest + != replace( + reservation, + admission_lineage=replace(lineage, packet_sha256="sha256:" + "9" * 64), + ).request_digest + ) + with pytest.raises(ValueError, match="reservation"): + replace(reservation, semantic_manifest_sha256=SHA) + + +def test_submission_artifact_request_selects_exactly_one_pre_lineage() -> None: + selected_id = new_record_id() + request = SubmissionBundleArtifactAdmissionRequest( + pre_submit_evidence_set_id=None, + custody=object(), + replay_durable_intent_id=None, + selected_pre_submit_run_id=selected_id, + selected_terminal_digest=SHA, + ) + assert request.selected_pre_submit_run_id == selected_id + with pytest.raises(ValueError, match="lineage"): + SubmissionBundleArtifactAdmissionRequest( + pre_submit_evidence_set_id=new_record_id(), + custody=object(), + replay_durable_intent_id=None, + selected_pre_submit_run_id=selected_id, + selected_terminal_digest=SHA, + ) + with pytest.raises(ValueError, match="lineage"): + replace(request, selected_terminal_digest=None) + + +def test_selected_ready_admission_binds_selected_run() -> None: + run_id = new_record_id() + admission = SimpleNamespace( + id=str(new_record_id()), + pre_submit_evidence_set_id=None, + selected_pre_submit_run_id=str(run_id), + artifact_content_id=str(new_record_id()), + actor_profile_id=str(new_record_id()), + identity_link_id=str(new_record_id()), + project_id=str(new_record_id()), + task_id=str(new_record_id()), + assignment_id=str(new_record_id()), + predecessor_submission_id=None, + predecessor_submission_version=None, + checker_remediation_manifest_id=None, + guide_id=str(new_record_id()), + guide_version="v1", + source_snapshot_id=str(new_record_id()), + source_snapshot_sha256=SHA, + effective_policy_id=str(new_record_id()), + effective_policy_sha256=SHA, + pre_submit_policy_id=str(new_record_id()), + pre_submit_policy_sha256=SHA, + selected_post_submit_policy_sha256=_POST_POLICY_SHA256, + selected_post_submit_policy_body=_POST_POLICY, + locked_policy_context_hash=SHA, + semantic_manifest_id=str(new_record_id()), + semantic_manifest_sha256=SHA, + archive_sha256=SHA, + archive_byte_count=42, + ) + content = SimpleNamespace( + id=admission.artifact_content_id, + sha256=admission.archive_sha256, + byte_count=admission.archive_byte_count, + ) + request = SimpleNamespace( + admission_id=UUID(admission.id), + submission_id=new_record_id(), + submission_version=1, + ) + + assert SubmissionAdmissionConsumptionService._art_lineage_is_intact(admission, content) + facts = SubmissionAdmissionConsumptionService._authority_facts(admission, request) + assert facts.evidence_set_id is None + assert facts.selected_pre_submit_run_id == run_id diff --git a/backend/tests/checkers/external/test_selected_pre_submit_service.py b/backend/tests/checkers/external/test_selected_pre_submit_service.py new file mode 100644 index 000000000..cf9f8469a --- /dev/null +++ b/backend/tests/checkers/external/test_selected_pre_submit_service.py @@ -0,0 +1,223 @@ +"""Selected PRE work stays outside short claim and finalization roots.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest + +from app.core.identifiers import new_record_id +from app.modules.artifacts.selected_pre_submit_execution import ( + SelectedPreSubmitExecutionService, +) +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, +) +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitAuthorityEvidence, + SelectedPreSubmitExecuteFacts, + SelectedPreSubmitFinalizeFacts, + SelectedPreSubmitInfrastructureUnavailable, + SelectedPreSubmitLeaseFacts, + SelectedPreSubmitReservationFacts, +) + + +class _Session: + def __init__(self, order: list[str]) -> None: + self.active = False + self.order = order + + @asynccontextmanager + async def begin(self): + assert not self.active + self.active = True + self.order.append("begin") + try: + yield + finally: + self.active = False + self.order.append("commit") + + +class _Prepared: + def __init__(self, order: list[str], phase: str) -> None: + self.order = order + self.phase = phase + + async def consume(self, facts): + self.order.append("consume_" + self.phase) + return SelectedPreSubmitAuthorityEvidence( + evidence_id=new_record_id(), facts_digest=facts.facts_digest + ) + + +class _Authority: + def __init__(self, session: _Session, order: list[str]) -> None: + self.session = session + self.order = order + + @asynccontextmanager + async def prepare_execution(self, _reservation): + assert self.session.active + self.order.append("prepare_execute") + yield _Prepared(self.order, "execute") + + @asynccontextmanager + async def prepare_finalization(self, _facts): + assert self.session.active + self.order.append("prepare_finalize") + yield _Prepared(self.order, "finalize") + + +class _Coordinator: + def __init__(self, session: _Session, order: list[str], lease) -> None: + self.session = session + self.order = order + self.lease = lease + + async def claim(self, *, authority, **_values): + assert self.session.active + self.order.append("claim") + await authority.consume(SelectedPreSubmitExecuteFacts(self.lease)) + return self.lease + + async def committed_execution(self, **_values): + assert not self.session.active + self.order.append("committed_execution") + return object() + + async def read_expired_lease(self, **_values): + assert self.session.active + self.order.append("read_expired_lease") + return self.lease + + async def finalize(self, *, authority, execution, infrastructure_failure_code, **_values): + assert self.session.active + self.order.append("finalize") + facts = SelectedPreSubmitFinalizeFacts( + lease=self.lease, + execution=execution, + infrastructure_failure_code=infrastructure_failure_code, + ) + await authority.consume(facts) + return SimpleNamespace(terminal=facts, capability=None) + + +class _Materialization: + def __init__(self, session: _Session, order: list[str]) -> None: + self.session = session + self.order = order + + async def materialize_selected_pre_submit(self, *_args, **_kwargs): + assert not self.session.active + self.order.append("materialize") + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") + + +def _reservation() -> SelectedPreSubmitReservationFacts: + return SelectedPreSubmitReservationFacts( + actor_profile_id=new_record_id(), + identity_link_id=new_record_id(), + project_id=new_record_id(), + task_id=new_record_id(), + assignment_id=new_record_id(), + attempt_id=new_record_id(), + idempotency_key=new_record_id(), + attempt_request_digest="sha256:" + "0" * 64, + prepared_generation_id=new_record_id(), + policy_sha256="sha256:" + "1" * 64, + default_behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + default_input_sha256="sha256:" + "2" * 64, + archive_sha256="sha256:" + "3" * 64, + archive_byte_count=100, + semantic_manifest_sha256="sha256:" + "4" * 64, + execution_budget_ms=60_000, + ) + + +@pytest.mark.asyncio +async def test_selected_pre_execution_commits_lease_before_work_and_finalizes_after() -> None: + order: list[str] = [] + session = _Session(order) + reservation = _reservation() + lease = SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=new_record_id(), + lease_generation=1, + lease_expires_at=datetime.now(UTC) + timedelta(minutes=1), + ) + service = SelectedPreSubmitExecutionService( + session=session, + coordinator=_Coordinator(session, order, lease), + authority=_Authority(session, order), + materialization=_Materialization(session, order), + ) + + completed = await service.execute( + reservation=reservation, + materialization=SimpleNamespace(), + ) + + assert completed.terminal.infrastructure_failure_code == "material_unavailable" + assert order == [ + "begin", + "prepare_execute", + "claim", + "consume_execute", + "commit", + "committed_execution", + "materialize", + "begin", + "prepare_finalize", + "finalize", + "consume_finalize", + "commit", + ] + + +@pytest.mark.asyncio +async def test_selected_pre_recovery_finalizes_expired_lease_without_material() -> None: + order: list[str] = [] + session = _Session(order) + reservation = _reservation() + lease = SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=new_record_id(), + lease_generation=1, + lease_expires_at=datetime.now(UTC) - timedelta(seconds=1), + ) + service = SelectedPreSubmitExecutionService( + session=session, + coordinator=_Coordinator(session, order, lease), + authority=_Authority(session, order), + materialization=_Materialization(session, order), + ) + + completed = await service.recover_expired( + run_id=reservation.attempt_id, + reservation=reservation, + ) + + assert completed.terminal.infrastructure_failure_code == "deadline_exceeded" + assert order == [ + "begin", + "read_expired_lease", + "commit", + "begin", + "prepare_finalize", + "finalize", + "consume_finalize", + "commit", + ] + + with pytest.raises( + SelectedPreSubmitInfrastructureUnavailable, + match="material_unavailable", + ): + await service.recover_expired( + run_id=new_record_id(), + reservation=reservation, + ) diff --git a/backend/tests/checkers/external/test_selected_preparation_command.py b/backend/tests/checkers/external/test_selected_preparation_command.py new file mode 100644 index 000000000..c3855a457 --- /dev/null +++ b/backend/tests/checkers/external/test_selected_preparation_command.py @@ -0,0 +1,191 @@ +"""The hidden contributor command selects the durable PRE owner exclusively.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import app.modules.artifacts.submission_admission as owner +from app.core.identifiers import new_record_id +from app.modules.artifacts.api import ( + SubmissionBundlePreparationRequest, + SubmissionBundlePreparationStatus, +) +from app.modules.artifacts.submission_admission import ( + PreparedSubmissionBundlePreparationCommand, + SubmissionBundleDurablePutResult, + SubmissionBundlePreparationRuntime, +) +from app.modules.authorization.api import ActorIdentityFacts, ActorKind +from app.modules.checkers.api.selected_pre_submit import SelectedPreSubmitCompletion + + +@asynccontextmanager +async def _root(): + yield + + +def _request_case(): + actor = ActorIdentityFacts( + actor_profile_id=new_record_id(), + identity_link_id=new_record_id(), + actor_kind=ActorKind.HUMAN, + ) + task_id, assignment_id, attempt_id = (new_record_id() for _ in range(3)) + request = SubmissionBundlePreparationRequest( + actor=actor, + request_id=new_record_id(), + correlation_id=new_record_id(), + task_id=task_id, + assignment_id=assignment_id, + predecessor_submission_id=None, + idempotency_key=new_record_id(), + summary="", + contributor_attestation="", + media_type="application/zip", + byte_source=SimpleNamespace(), + ) + prepared = SimpleNamespace( + generation_id=new_record_id(), + commitment=SimpleNamespace(sha256="sha256:" + "1" * 64, byte_count=100), + inspect=AsyncMock(return_value=object()), + close=AsyncMock(), + ) + return actor, request, prepared, attempt_id + + +def _runtime_case(prepared, attempt_id): + policy = object() + selected = SimpleNamespace(policy=policy, default_input=object(), change_gate=object()) + reservation = SimpleNamespace(attempt_id=attempt_id) + completion = SelectedPreSubmitCompletion( + terminal=SimpleNamespace( + outcome="completed", + execution=SimpleNamespace(eligible=True), + infrastructure_failure_code=None, + ), + capability=object(), + ) + materialization = SimpleNamespace( + prepare_selected_authorization=AsyncMock(return_value=object()), + authorize_inspected=AsyncMock(), + ) + coordinator = SimpleNamespace( + read_current=AsyncMock(return_value=None), + reserve=AsyncMock(return_value=attempt_id), + ) + selected_execution = SimpleNamespace(execute=AsyncMock(return_value=completion)) + durable = SimpleNamespace( + admit_in_transaction=AsyncMock(return_value=(prepared, attempt_id, SimpleNamespace())), + publish_after_commit=AsyncMock( + return_value=SubmissionBundleDurablePutResult( + put_attempt_id=new_record_id(), + operation_identity="sha256:" + "2" * 64, + status="prepared", + replayed=False, + admission_id=None, + selected_pre_submit_run_id=attempt_id, + ) + ), + ) + runtime = SubmissionBundlePreparationRuntime( + preparation=SimpleNamespace(prepare=AsyncMock(return_value=prepared)), + inspector=object(), + materialization=materialization, + durable_put=durable, + selected_coordinator=coordinator, + selected_execution=selected_execution, + storage_scheme="s3", + artifact_deadline_ms=120_000, + ) + return SimpleNamespace( + runtime=runtime, + policy=policy, + selected=selected, + reservation=reservation, + completion=completion, + materialization=materialization, + coordinator=coordinator, + selected_execution=selected_execution, + durable=durable, + ) + + +def _command(runtime, actor): + @asynccontextmanager + async def runtime_factory(): + yield runtime + + authority = SimpleNamespace(preflight=AsyncMock(), transaction=_root, close=Mock()) + command = PreparedSubmissionBundlePreparationCommand( + session=SimpleNamespace(begin=_root), + authority=authority, + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), + runtime_factory=runtime_factory, + ) + project = SimpleNamespace( + effective_policy_id=new_record_id(), + pre_submit_policy_id=new_record_id(), + external_checker_policies=SimpleNamespace(value="{}"), + ) + command._lock_authorized_context = AsyncMock( + return_value=(SimpleNamespace(predecessor=None, checker_remediation_manifest_id=None), project) + ) + command._load_predecessor = AsyncMock(return_value=None) + command._load_predecessor_rules = AsyncMock(return_value=None) + return command + + +def _patch_selected_owners(monkeypatch, case): + monkeypatch.setattr( + owner, + "LockedExternalCheckerPolicies", + SimpleNamespace( + model_validate_json=Mock(return_value=SimpleNamespace(pre_submit=case.policy)) + ), + ) + monkeypatch.setattr(owner, "build_submission_manifest", Mock(return_value=object())) + monkeypatch.setattr( + owner, "bind_selected_pre_submit_admission", Mock(return_value=case.selected) + ) + monkeypatch.setattr( + owner, + "build_selected_pre_submit_reservation", + Mock(return_value=case.reservation), + ) + monkeypatch.setattr( + owner, + "selected_pre_submit_attempt_request_digest", + Mock(return_value="sha256:" + "3" * 64), + ) + monkeypatch.setattr(owner, "selected_pre_submit_execution_budget_ms", Mock(return_value=60_000)) + + +@pytest.mark.asyncio +async def test_hidden_preparation_uses_selected_pre_before_durable_admission( + monkeypatch, +) -> None: + actor, request, prepared, attempt_id = _request_case() + case = _runtime_case(prepared, attempt_id) + command = _command(case.runtime, actor) + _patch_selected_owners(monkeypatch, case) + + result = await command.prepare(request) + + assert result.submission_bundle_preparation_status is ( + SubmissionBundlePreparationStatus.PREPARED + ) + case.materialization.prepare_selected_authorization.assert_awaited_once() + case.materialization.authorize_inspected.assert_awaited_once() + case.coordinator.read_current.assert_awaited_once_with(case.reservation) + case.coordinator.reserve.assert_awaited_once_with(case.reservation) + case.selected_execution.execute.assert_awaited_once() + admitted = case.durable.admit_in_transaction.await_args.args[0] + assert admitted.selected_completion is case.completion + case.durable.publish_after_commit.assert_awaited_once_with( + prepared, + attempt_id, + case.durable.admit_in_transaction.return_value[2], + ) diff --git a/backend/tests/checkers/post_submit/support.py b/backend/tests/checkers/post_submit/support.py index 3d3c12817..8e5beea63 100644 --- a/backend/tests/checkers/post_submit/support.py +++ b/backend/tests/checkers/post_submit/support.py @@ -1,101 +1,9 @@ -"""Controlled owner facts; no future database rows, authorization, or worker fixtures.""" +"""Current selected request identity mutations for authority and lineage controls.""" -from uuid import UUID -from app.core.identifiers import new_record_id - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api import ( - ExpectedPostSubmitContext, - ObservedPostSubmitContext, - PostSubmissionStructuralInput, - PostSubmitEvidenceEntry, - PostSubmitManifestEntry, - PostSubmitPolicyInputs, -) -from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue, PostSubmitDefinition -from app.modules.checkers.post_submit_catalogue import build_post_submit_catalogue -from app.modules.checkers.api.post_submit import make_post_submit_request -from app.modules.checkers.runner import default_checker_registry -from app.modules.projects.post_submit_policy import build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec - -HASH = "sha256:" + "a" * 64 -OTHER_HASH = "sha256:" + "b" * 64 -PROJECT = UUID("11111111-1111-4111-8111-111111111111") - - -def catalogue(): - return build_post_submit_catalogue(default_checker_registry()) - - -def altered_catalogue(original=None, *, index=8, **changes): - source = original or catalogue() - body = source.model_dump(mode="json", exclude={"manifest_sha256"}) - body["definitions"][index].update(changes) - body["definitions"][index] = PostSubmitDefinition.model_validate( - body["definitions"][index] - ).model_dump(mode="json") - return PostSubmitCatalogue(**body, manifest_sha256=canonical_json_hash(body)) - - -def request(*, snapshot=None, project_id=PROJECT): - snapshot = snapshot or catalogue() - policy = compile_project_post_submit_checker_spec( - project_id=str(project_id), guide_version="v1", - spec=build_project_post_submit_checker_spec( - project_id=str(project_id), guide_version="v1", - required_checkers=["check_acceptance_criteria_present"], - ), - ) - policy.validate_catalogue(snapshot) - fields = {} - for name in ExpectedPostSubmitContext.model_fields: - if name.endswith("_id"): - fields[name] = new_record_id() - elif "version" in name: - fields[name] = "v1" - elif name.endswith("generation"): - fields[name] = 1 - else: - fields[name] = HASH - fields.update(post_policy_hash=policy.policy_hash) - expected = ExpectedPostSubmitContext(**fields) - data = PostSubmissionStructuralInput( - summary="Delivered the documented result with supporting evidence.", - worker_attestation="I confirm this submission contains no confidential data, credentials, secrets, or copied source or platform artifacts. Rights confirmed.", - package_hash=HASH, - criteria="Provide an accurate analysis with supporting evidence.", - manifest=(PostSubmitManifestEntry(artifact="report.txt", hash=HASH, size_bytes=10),), - evidence=( - PostSubmitEvidenceEntry(label="proof", type="file", uri="report.txt", hash=HASH), - ), - policy_inputs=PostSubmitPolicyInputs( - required_evidence_keys=("proof",), - required_artifact_paths=("report.txt",), - forbidden_artifact_patterns=("forbidden/**",), - required_attestation_terms=("rights confirmed",), - ), - observed_context=ObservedPostSubmitContext(**expected.model_dump()), - ) - return make_post_submit_request( - evaluation_request_id=new_record_id(), - evaluation_generation=1, - project_id=project_id, - task_id=new_record_id(), - assignment_id=new_record_id(), - submission_id=new_record_id(), - submission_version=1, - content_id=new_record_id(), - binding_id=new_record_id(), - content_sha256=HASH, - byte_count=10, - expected_context=expected, - catalogue=snapshot, - policy=policy, - structural_input=data, - ) +from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_request def change_request(source, **changes): fields = source.model_dump(exclude={"request_sha256"}) fields.update(changes) - return make_post_submit_request(**fields) + return make_selected_post_submit_request(**fields) diff --git a/backend/tests/checkers/post_submit/test_catalogue.py b/backend/tests/checkers/post_submit/test_catalogue.py deleted file mode 100644 index a65e6635d..000000000 --- a/backend/tests/checkers/post_submit/test_catalogue.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Registry installation, identity, and single-handler installation proof.""" - -from dataclasses import replace - -import pytest -from pydantic import ValidationError - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api.post_submit_catalogue import ( - structural_definition, - PostSubmitCatalogue, - PostSubmitDefinition, -) -from app.modules.checkers.post_submit_catalogue import ( - build_post_submit_catalogue, -) -from app.modules.checkers.runner import ( - CheckerNameConflict, - CheckerRegistry, - FunctionChecker, - UnknownChecker, - check_policy_context_present, - default_checker_registry, -) -from tests.checkers.post_submit.support import altered_catalogue, catalogue - - -def test_catalogue_matches_exactly_one_registered_handler_per_id(): - registry = default_checker_registry() - snapshot = build_post_submit_catalogue(registry) - assert len(snapshot.definitions) == 9 - assert len(registry._checkers) == 9 - for definition in snapshot.definitions: - entry = registry.resolve(definition.capability_id) - assert entry.definition == definition - assert entry.checker.name == definition.capability_id - entry = registry.resolve("check_policy_context_present") - assert entry.checker._handler is check_policy_context_present - assert entry.definition in snapshot.definitions - assert registry.names() == {item.capability_id for item in snapshot.definitions} - - -@pytest.mark.parametrize("damage", ("missing", "version", "identity")) -def test_catalogue_rejects_uninstalled_or_mismatched_handler(damage): - registry = default_checker_registry() - key = "check_submission_packet" - entry = registry._checkers.pop(key) - if damage == "version": - registry._checkers[key] = replace( - entry, - definition=entry.definition.model_copy(update={"implementation_version": "wrong"}), - ) - elif damage == "identity": - registry._checkers[key] = replace( - entry, checker=FunctionChecker("other", entry.checker._handler) - ) - with pytest.raises((ValueError, UnknownChecker), match="incomplete|unregistered|mismatch"): - build_post_submit_catalogue(registry) - - -def test_registry_rejects_duplicates_and_metadata_substitution(): - registry = default_checker_registry() - checker = registry.resolve("check_submission_packet").checker - with pytest.raises(CheckerNameConflict, match="already registered"): - registry.register(checker, definition=structural_definition(checker.name)) - with pytest.raises(ValueError, match="definition mismatch"): - CheckerRegistry().register( - checker, definition=structural_definition("check_required_files") - ) - with pytest.raises(UnknownChecker, match="unregistered checker policy names"): - registry.require_registered({"missing"}) - with pytest.raises(ValueError, match="unknown structural"): - structural_definition("missing") - - -@pytest.mark.parametrize( - "change", - [ - {"state": "disabled"}, - {"implementation_version": "another-implementation"}, - {"supported_claim": "packet_presence"}, - {"dependencies": ["check_submission_packet"]}, - ], -) -def test_catalogue_hash_binds_semantics(change): - original = catalogue() - changed = altered_catalogue(original, **change) - assert changed.manifest_sha256 != original.manifest_sha256 - assert catalogue().manifest_sha256 == original.manifest_sha256 - exported = original.model_dump(mode="json") - exported["definitions"][0]["state"] = "disabled" - assert original.definitions[0].resources.deadline_ms == 5000 - with pytest.raises(ValidationError, match="hash mismatch"): - PostSubmitCatalogue.model_validate(exported) - - -@pytest.mark.parametrize( - "kind", ("duplicate", "order", "dependency", "hash", "classification", "severity") -) -def test_invalid_catalogue_shape(kind): - body = catalogue().model_dump(mode="json", exclude={"manifest_sha256"}) - if kind == "duplicate": - body["definitions"][1]["capability_id"] = body["definitions"][0]["capability_id"] - elif kind == "order": - body["definitions"][0]["order"] = 9 - elif kind == "dependency": - body["definitions"][0]["dependencies"] = ["missing"] - elif kind == "classification": - body["definitions"][0]["selectable"] = True - elif kind == "severity": - body["definitions"][0]["failure_severity"] = "medium" - digest = "sha256:" + "0" * 64 if kind == "hash" else canonical_json_hash(body) - with pytest.raises(ValidationError): - PostSubmitCatalogue(**body, manifest_sha256=digest) - - -def test_definition_validates_boolean_resource_limits_and_unknown_fields(): - definition = catalogue().definitions[0].model_dump() - definition["resources"] = {"maximum_results": True} - with pytest.raises(ValidationError, match="require integers"): - PostSubmitDefinition(**definition) - with pytest.raises(ValidationError, match="Extra inputs"): - PostSubmitDefinition(**catalogue().definitions[0].model_dump(), arbitrary="secret") - - -def test_hash_valid_catalogue_rejects_duplicate_dependencies(): - body = catalogue().model_dump(mode="json", exclude={"manifest_sha256"}) - dependency = body["definitions"][0]["capability_id"] - body["definitions"][1]["dependencies"] = [dependency, dependency] - with pytest.raises(ValidationError, match="definition has duplicate dependencies"): - PostSubmitCatalogue(**body, manifest_sha256=canonical_json_hash(body)) diff --git a/backend/tests/checkers/post_submit/test_compiled_policy.py b/backend/tests/checkers/post_submit/test_compiled_policy.py deleted file mode 100644 index 60a0d15f1..000000000 --- a/backend/tests/checkers/post_submit/test_compiled_policy.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Canonical compiler custody, closed-body rejection and policy classification proof.""" - -import json - -import pytest -from pydantic import ValidationError - -from app.core.hashing import canonical_json_hash -from app.modules.checkers.api import CompiledPostSubmitPolicy -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue -from app.modules.projects.post_submit_policy import build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec, parse_locked_post_submit_checker_policy_body -from tests.checkers.post_submit.support import PROJECT, altered_catalogue, catalogue, request - - -def compile_selection(*, required=(), warning=()): - return compile_project_post_submit_checker_spec( - project_id=str(PROJECT), - guide_version="v1", - spec=build_project_post_submit_checker_spec( - project_id=str(PROJECT), - guide_version="v1", - required_checkers=list(required), - warning_checkers=list(warning), - ), - ) - - -def parse(body, *, digest=None): - parsed = parse_locked_post_submit_checker_policy_body( - body, - project_id=str(PROJECT), - guide_version="v1", - policy_hash=canonical_json_hash(body) if digest is None else digest, - ) - - parsed.validate_catalogue(current_post_submit_catalogue()) - return parsed - - -def test_canonical_compile_parse_and_derived_lists(): - policy = request().policy - assert parse(policy.policy_body) == policy - assert policy.entries[1].implementation_version == "workstream-structural" - assert len(policy.entries) == 9 - assert len(compile_selection().entries) == 8 - warning = compile_selection(warning=("check_acceptance_criteria_present",)) - assert warning.entries[-1].classification == "project_warning" - assert warning.warning_checkers == ["check_acceptance_criteria_present"] - assert warning.required_checkers == [] - assert policy.required_checkers == ["check_acceptance_criteria_present"] - assert policy.execution_checkers == policy.default_checkers + policy.required_checkers - - -@pytest.mark.parametrize( - "required,warning", - [ - (("unknown",), ()), - (("check_submission_packet",), ()), - ((), ("check_submission_packet",)), - (("check_acceptance_criteria_present",), ("check_acceptance_criteria_present",)), - (("check_acceptance_criteria_present", "check_acceptance_criteria_present"), ()), - ], -) -def test_compiler_rejects_unavailable_or_conflicting_project_selection(required, warning): - with pytest.raises( - ValueError, match="selection is unavailable|conflicting|duplicate|warning-only" - ): - compile_selection(required=required, warning=warning) - - -@pytest.mark.parametrize("index", (0, 8)) -def test_disabled_default_or_selected_definition_rejects(index): - policy = request().policy - snapshot = altered_catalogue(index=index, state="disabled") - body = policy.policy_body - body["catalogue_manifest_sha256"] = snapshot.manifest_sha256 - with pytest.raises(ValueError, match="definition is unavailable"): - CompiledPostSubmitPolicy.model_validate_json(json.dumps(body)).validate_catalogue(snapshot) - # Self-consistent metadata still cannot replace the installed compiler catalogue. - with pytest.raises(ValueError, match="catalogue hash mismatch"): - parse(body) - - -@pytest.mark.parametrize( - "change,match", - [ - ("hash", "policy hash is invalid"), - ("catalogue", "catalogue hash mismatch"), - ("implementation", "implementation version mismatch"), - ("omit", "omits mandatory"), - ("default", "classification mismatch"), - ("order", "order or dependency"), - ("repeat", "duplicate entries"), - ("severity", "blocking severities"), - ("configuration", "Extra inputs"), - ("checker", "capability or version is unavailable"), - ("schema_version", "Input should be"), - ("compiler_version", "Input should be"), - ("catalogue_id", "Input should be"), - ("catalogue_source_version", "Input should be"), - ("catalogue_schema_version", "Input should be"), - ], -) -def test_recomputed_hash_cannot_hide_invalid_current_body(change, match): - body = request().policy.policy_body - if change == "hash": - with pytest.raises(ValueError, match=match): - parse(body, digest="sha256:" + "0" * 64) - return - if change == "catalogue": - body["catalogue_manifest_sha256"] = "sha256:" + "0" * 64 - elif change == "implementation": - body["entries"][1]["implementation_version"] = "unsupported" - elif change == "omit": - del body["entries"][0] - elif change == "default": - body["entries"][0]["classification"] = "project_required" - elif change == "order": - body["entries"][0], body["entries"][1] = body["entries"][1], body["entries"][0] - elif change == "repeat": - body["entries"][1] = body["entries"][0] - elif change == "severity": - body["blocking_severities"] = ["low", "medium"] - elif change == "configuration": - body["entries"][0]["configuration"] = {"skip": True} - elif change == "checker": - body["entries"][0]["checker_id"] = "unknown" - else: - body[change] = "unsupported" - with pytest.raises(ValueError, match=match): - parse(body) - - -def test_agent_projection_is_exact_current_catalogue(): - projection = current_post_submit_catalogue() - assert projection.model_dump(mode="json") == catalogue().model_dump(mode="json") - corrupted = projection.model_dump() - corrupted["definitions"][0]["state"] = "disabled" - with pytest.raises(ValidationError, match="hash mismatch"): - type(projection).model_validate(corrupted) - - -def test_sparse_policy_with_recomputed_hash_has_no_reader(): - policy = request().policy - body = { - "schema_version": "post_submit_checker_policy", - "compiler_version": "workstream-post-submit-compiler", - "project_id": str(PROJECT), - "guide_version": "v1", - "default_checkers": policy.default_checkers, - "required_checkers": policy.required_checkers, - "warning_checkers": [], - "execution_checkers": policy.execution_checkers, - "blocking_severities": ["critical", "high"], - } - with pytest.raises(ValidationError, match="Extra inputs"): - parse(body) - - -@pytest.mark.parametrize("field", ("required_checkers", "warning_checkers", "blocking_severities")) -def test_each_persisted_sidecar_must_equal_canonical_body(field): - policy = request().policy - values = dict( - required_checkers=policy.required_checkers, - warning_checkers=policy.warning_checkers, - blocking_severities=list(policy.blocking_severities), - ) - policy.validate_sidecars(**values) - values[field] = [*values[field], "crossed"] - with pytest.raises(ValueError, match="summaries disagree"): - policy.validate_sidecars(**values) diff --git a/backend/tests/checkers/post_submit/test_configuration.py b/backend/tests/checkers/post_submit/test_configuration.py deleted file mode 100644 index b71f22514..000000000 --- a/backend/tests/checkers/post_submit/test_configuration.py +++ /dev/null @@ -1,25 +0,0 @@ -"""No arbitrary post-submit configuration or implicit version selection.""" - -import pytest -from pydantic import ValidationError - -from tests.checkers.post_submit.support import altered_catalogue, catalogue - - -def test_binding_uses_registered_configuration(): - definition = catalogue().definition("check_acceptance_criteria_present", "v0.1") - assert definition.validate_configuration({}).model_dump() == {} - with pytest.raises(ValidationError, match="Extra inputs"): - definition.validate_configuration({"script": "arbitrary"}) - with pytest.raises(ValueError, match="unavailable"): - altered_catalogue(state="disabled").definition( - "check_acceptance_criteria_present", "v0.1" - ).validate_configuration({}) - - -@pytest.mark.parametrize( - "name,version", [("unknown", "v0.1"), ("check_acceptance_criteria_present", "unsupported")] -) -def test_missing_exact_definition_does_not_select_latest(name, version): - with pytest.raises(ValueError, match="capability or version is unavailable"): - catalogue().definition(name, version) diff --git a/backend/tests/checkers/post_submit/test_conformance.py b/backend/tests/checkers/post_submit/test_conformance.py deleted file mode 100644 index 22f4b10b0..000000000 --- a/backend/tests/checkers/post_submit/test_conformance.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Real registered handlers and reachable counterexamples.""" - -from dataclasses import replace -from app.core.identifiers import new_record_id - -import pytest - -from app.modules.checkers.api import ExpectedPostSubmitContext -from app.modules.checkers.post_submit_implementations import ( - bounded_structural_result, - detached_checker_context, - evaluate_registered_structural_member, -) -from app.modules.checkers.runner import ( - check_policy_context_present, - CheckerOutcome, - default_checker_registry, -) -from tests.checkers.post_submit.support import change_request, request - -CASES = ( - ("check_submission_packet", "packet_fields_missing", "failed"), - ("check_policy_context_present", "policy_context_invalid", "failed"), - ("check_evidence_present", "evidence_missing", "failed"), - ("check_evidence_integrity", "evidence_structure_invalid", "failed"), - ("check_required_files", "required_files_missing", "failed"), - ("check_forbidden_files", "forbidden_path_present", "failed"), - ("check_confidentiality_attestation", "attestation_missing", "failed"), - ("check_low_quality_generated_artifacts", "placeholder_signal", "warning"), - ("check_acceptance_criteria_present", "acceptance_criteria_missing", "failed"), -) - - -def damaged_input(source, name): - data = source.structural_input.model_dump() - if name == "check_submission_packet": - data["summary"] = "" - elif name == "check_policy_context_present": - data["observed_context"]["post_policy_id"] = None - elif name == "check_evidence_present": - data["evidence"] = () - elif name == "check_evidence_integrity": - data["manifest"][0]["hash"] = "invalid" - elif name == "check_required_files": - data["manifest"][0]["artifact"] = "another.txt" - elif name == "check_forbidden_files": - data["manifest"][0]["artifact"] = "forbidden/data.txt" - elif name == "check_confidentiality_attestation": - data["worker_attestation"] = data["worker_attestation"].replace("Rights confirmed.", "") - elif name == "check_low_quality_generated_artifacts": - data["summary"] = "TODO: add actual content" - elif name == "check_acceptance_criteria_present": - data["criteria"] = " " - return change_request(source, structural_input=data) - - -@pytest.mark.parametrize("name,code,status", CASES) -async def test_structural_conformance(name, code, status): - registry = default_checker_registry() - source = request() - definition = source.catalogue.definition(name, "v0.1") - control = await evaluate_registered_structural_member(registry, definition, source) - assert (control.status, control.code, control.severity, control.failure_category) == ( - "passed", - "passed", - "info", - "none", - ) - result = await evaluate_registered_structural_member( - registry, definition, damaged_input(source, name) - ) - assert (result.status, result.code) == (status, code) - assert result.severity == ("medium" if status == "warning" else "high") - assert result.failure_category == ( - "task_configuration" - if name in {"check_policy_context_present", "check_acceptance_criteria_present"} - else "submission_structure" - ) - assert "message" not in result.model_dump() - - -@pytest.mark.parametrize("field", tuple(ExpectedPostSubmitContext.model_fields)) -@pytest.mark.parametrize("kind", ("missing", "crossed")) -async def test_current_policy_context_without_payment(field, kind): - source = request() - data = source.structural_input.model_dump() - old = data["observed_context"][field] - replacement = None - if kind == "crossed": - replacement = ( - new_record_id() - if field.endswith("_id") - else 2 - if type(old) is int - else ("sha256:" + "b" * 64 if str(old).startswith("sha256:") else "other") - ) - data["observed_context"][field] = replacement - changed = change_request(source, structural_input=data) - definition = source.catalogue.definition("check_policy_context_present", "v0.1") - registry = default_checker_registry() - assert ( - await evaluate_registered_structural_member(registry, definition, source) - ).status == "passed" - result = await evaluate_registered_structural_member(registry, definition, changed) - assert result.code == "policy_context_invalid" - assert result.status == "failed" - - -async def test_registry_executes_canonical_entries_with_current_context(): - source = request() - outcomes = await default_checker_registry().run( - detached_checker_context(source), source.policy.entries - ) - assert [item.checker_name for item in outcomes] == source.policy.execution_checkers - assert all(item.status == "passed" for item in outcomes) - - -async def test_presence_check_does_not_claim_substantive_quality(): - source = request() - changed = change_request( - source, - structural_input={ - **source.structural_input.model_dump(), - "summary": "An incorrect analysis with unsupported conclusions.", - }, - ) - definition = source.catalogue.definition("check_acceptance_criteria_present", "v0.1") - result = await evaluate_registered_structural_member( - default_checker_registry(), definition, changed - ) - assert result.status == "passed" - assert definition.supported_claim == "criteria_presence" - - -@pytest.mark.parametrize("mutation", ("identity", "severity", "status")) -def test_bounded_result_rejects_malformed_handler_output(mutation): - definition = request().catalogue.definitions[0] - values = dict( - checker_name=definition.capability_id, status="failed", severity="high", message="secret" - ) - values[{"identity": "checker_name", "severity": "severity", "status": "status"}[mutation]] = { - "identity": "other", - "severity": "low", - "status": "accept", - }[mutation] - with pytest.raises(ValueError): - bounded_structural_result(definition, CheckerOutcome(**values)) - - -async def test_context_handler_requires_observed_input_and_definitions_are_exact(): - source = request() - context = detached_checker_context(source) - context = replace(context, observed_context=None) - with pytest.raises(ValueError, match="requires expected and observed"): - await check_policy_context_present(context) - definition = source.catalogue.definitions[0].model_copy(update={"state": "disabled"}) - with pytest.raises(ValueError, match="definition mismatch"): - await evaluate_registered_structural_member(default_checker_registry(), definition, source) - registry = default_checker_registry() - key = source.catalogue.definitions[0].capability_id - registry._checkers[key] = registry._checkers["check_required_files"] - with pytest.raises(ValueError, match="installed definition mismatch"): - await evaluate_registered_structural_member( - registry, source.catalogue.definitions[0], source - ) - - -@pytest.mark.parametrize( - "damage", ("duplicate", "empty_package", "empty_manifest", "missing_evidence_hash") -) -async def test_additional_claim_specific_failures_reach_handlers(damage): - source = request() - data = source.structural_input.model_dump() - name, code = "check_evidence_integrity", "evidence_structure_invalid" - if damage == "duplicate": - data["manifest"] = data["manifest"] * 2 - elif damage == "missing_evidence_hash": - data["evidence"][0]["hash"] = None - else: - name, code = "check_submission_packet", "packet_fields_missing" - if damage == "empty_package": - data["package_hash"] = "" - else: - data["manifest"] = () - changed = change_request(source, structural_input=data) - result = await evaluate_registered_structural_member( - default_checker_registry(), source.catalogue.definition(name, "v0.1"), changed - ) - assert result.code == code - - -@pytest.mark.parametrize("alias", ("./report.txt", ".\\report.txt", " report.txt ")) -async def test_normalized_duplicates_preserve_immutable_source(alias): - source = request() - data = source.structural_input.model_dump() - entry = data["manifest"][0] - data["manifest"] = (entry, {**entry, "artifact": alias}) - changed = change_request(source, structural_input=data) - original = changed.model_dump_json() - registry = default_checker_registry() - definition = changed.catalogue.definition("check_evidence_integrity", "v0.1") - outcome = await evaluate_registered_structural_member(registry, definition, changed) - assert (outcome.status, outcome.code) == ("failed", "evidence_structure_invalid") - assert changed.model_dump_json() == original - - -@pytest.mark.parametrize("path", ("../report.txt", "/report.txt", "./")) -async def test_invalid_copied_paths_reach_registered_failure(path): - source = request() - data = source.structural_input.model_dump() - data["manifest"][0]["artifact"] = path - changed = change_request(source, structural_input=data) - original = changed.model_dump_json() - outcome = await evaluate_registered_structural_member( - default_checker_registry(), - changed.catalogue.definition("check_evidence_integrity", "v0.1"), - changed, - ) - assert (outcome.status, outcome.code) == ("failed", "evidence_structure_invalid") - assert changed.model_dump_json() == original - - -@pytest.mark.parametrize( - "uri,expected", - ( - ("./report.txt", "report.txt"), - ("https://example.com/proof", "https://example.com/proof"), - ), -) -def test_copied_evidence_paths_preserve_external_uri_identity(uri, expected): - source = request() - data = source.structural_input.model_dump() - data["evidence"][0]["uri"] = uri - changed = change_request(source, structural_input=data) - assert detached_checker_context(changed).submission.evidence_items[0].uri == expected - assert changed.structural_input.evidence[0].uri == uri - - -@pytest.mark.parametrize( - "field", - ( - "payment_policy_version", - "compilation_id", - "compilation_result_hash", - "contribution_policy_version_id", - "guide_id", - "project_id", - ), -) -def test_context_rejects_unsupported_or_fabricated_observations(field): - from app.modules.checkers.api import ObservedPostSubmitContext - - values = request().structural_input.observed_context.model_dump() - with pytest.raises(ValueError, match="Extra inputs"): - ObservedPostSubmitContext.model_validate({**values, field: new_record_id()}) diff --git a/backend/tests/checkers/post_submit/test_input_bounds.py b/backend/tests/checkers/post_submit/test_input_bounds.py deleted file mode 100644 index ae059510b..000000000 --- a/backend/tests/checkers/post_submit/test_input_bounds.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Finite detached values, strict numbers and a reachable aggregate byte boundary.""" - -from collections import UserDict -from types import MappingProxyType - -import pytest -from pydantic import ValidationError - -from app.modules.checkers.api import ExpectedPostSubmitContext, PostSubmissionStructuralInput -from app.modules.checkers.api.post_submit import ( - POST_SUBMIT_CONTENT_BYTE_LIMIT, - PostSubmissionEvaluationContent, - PostSubmitEvidenceEntry, - PostSubmitManifestEntry, - PostSubmitPolicyInputs, -) -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitResourceLimits, - canonical_post_submit_bytes, -) -from tests.checkers.post_submit.support import HASH, change_request, request - - -@pytest.mark.parametrize("field", ("summary", "worker_attestation", "criteria")) -def test_structural_text_exact_limit_and_limit_plus_one(field): - fields = request().structural_input.model_dump() - fields[field] = "x" * 65536 - assert len(getattr(PostSubmissionStructuralInput(**fields), field)) == 65536 - fields[field] += "x" - with pytest.raises(ValidationError, match="65536"): - PostSubmissionStructuralInput(**fields) - - -@pytest.mark.parametrize( - "kind,field,limit", - [ - ("manifest", "artifact", 1000), - ("manifest", "hash", 128), - ("manifest", "notes", 4096), - ("evidence", "label", 200), - ("evidence", "uri", 1000), - ("evidence", "hash", 128), - ("evidence", "type", 200), - ("evidence", "key", 200), - ("evidence", "policy_key", 200), - ("evidence", "evidence_key", 200), - ("evidence", "required_evidence_key", 200), - ], -) -def test_entry_text_bounds(kind, field, limit): - cls = PostSubmitManifestEntry if kind == "manifest" else PostSubmitEvidenceEntry - values = ( - dict(artifact="file", hash=HASH) if kind == "manifest" else dict(label="label", type="file") - ) - values[field] = "x" * limit - assert len(getattr(cls(**values), field)) == limit - values[field] += "x" - with pytest.raises(ValidationError): - cls(**values) - - -@pytest.mark.parametrize("field", ("manifest", "evidence")) -def test_collection_limits_are_independent_of_request_aggregate(field): - data = request().structural_input.model_dump() - element = data[field][0] - data[field] = (element,) * 1024 - assert len(getattr(PostSubmissionStructuralInput(**data), field)) == 1024 - data[field] += (element,) - with pytest.raises(ValidationError, match="1024"): - PostSubmissionStructuralInput(**data) - - -@pytest.mark.parametrize("field", tuple(PostSubmitPolicyInputs.model_fields)) -def test_policy_input_count_and_token_limit(field): - assert len(getattr(PostSubmitPolicyInputs(**{field: ("x",) * 256}), field)) == 256 - assert getattr(PostSubmitPolicyInputs(**{field: ("x" * 1000,)}), field)[0] == "x" * 1000 - for value in (("x",) * 257, ("x" * 1001,)): - with pytest.raises(ValidationError): - PostSubmitPolicyInputs(**{field: value}) - - -@pytest.mark.parametrize("invalid", (True, -1, 9_223_372_036_854_775_808, "1")) -def test_strict_size_boundaries(invalid): - for valid in (0, 9_223_372_036_854_775_807): - assert ( - PostSubmitManifestEntry(artifact="x", hash=HASH, size_bytes=valid).size_bytes == valid - ) - assert change_request(request(), byte_count=valid).byte_count == valid - with pytest.raises(ValidationError): - PostSubmitManifestEntry(artifact="x", hash=HASH, size_bytes=invalid) - with pytest.raises(ValidationError): - change_request(request(), byte_count=invalid) - - -def test_version_types_and_numeric_generations_match_existing_owners(): - base = request().expected_context.model_dump() - expected = ExpectedPostSubmitContext( - **{**base, "guide_version": "x" * 50, "post_policy_version": "x" * 50} - ) - assert len(expected.guide_version) == 50 - for value in ("x" * 51, " ", 1): - with pytest.raises(ValidationError): - ExpectedPostSubmitContext( - **{**base, "guide_version": value, "post_policy_version": value} - ) - for field in ("review_generation", "revision_generation"): - assert ( - getattr(ExpectedPostSubmitContext(**{**base, field: 2_147_483_647}), field) - == 2_147_483_647 - ) - for value in (True, 0, 2_147_483_648, "1"): - with pytest.raises(ValidationError): - ExpectedPostSubmitContext(**{**base, field: value}) - for field in ("submission_version", "evaluation_generation"): - assert getattr(change_request(request(), **{field: 2_147_483_647}), field) == 2_147_483_647 - with pytest.raises(ValidationError): - change_request(request(), **{field: 2_147_483_648}) - - -def test_nested_extra_fields_and_strict_resource_ids_deny(): - base = request() - for owner in ( - base.structural_input, - base.structural_input.policy_inputs, - base.expected_context, - ): - with pytest.raises(ValidationError, match="Extra inputs"): - type(owner)(**owner.model_dump(), unexpected={"private": "data"}) - with pytest.raises(ValidationError, match="UUID"): - change_request(base, task_id=str(base.task_id)) - with pytest.raises(ValidationError): - change_request(base, content_sha256="sha256:invalid") - - -@pytest.mark.parametrize( - "field,maximum", - [ - ("maximum_input_bytes", 1_048_576), - ("maximum_result_bytes", 4096), - ("deadline_ms", 5000), - ("phase_deadline_ms", 45000), - ("maximum_manifest_items", 1024), - ("maximum_evidence_items", 1024), - ("maximum_policy_items", 256), - ("maximum_text_characters", 65536), - ], -) -def test_catalogue_resource_limits_match_exact_enforced_contract(field, maximum): - assert getattr(PostSubmitResourceLimits(**{field: maximum}), field) == maximum - for value in (True, 0, maximum - 1, maximum + 1, float(maximum), str(maximum)): - with pytest.raises(ValidationError): - PostSubmitResourceLimits(**{field: value}) - - -@pytest.mark.parametrize("prefix", ("", "é\n\"")) -def test_request_aggregate_exact_limit_and_one_byte_over(prefix): - source = request() - data = source.structural_input.model_dump() - data["summary"] = prefix - # Each item remains within its own limit; only the aggregate boundary varies. - data["manifest"] = tuple( - {"artifact": f"entry{i}", "hash": HASH, "notes": "x" * 3800, "size_bytes": 0} - for i in range(260) - ) - small = change_request(source, structural_input=data) - fields = set(PostSubmissionEvaluationContent.model_fields) - delta = POST_SUBMIT_CONTENT_BYTE_LIMIT - len(canonical_post_submit_bytes(small, include=fields)) - assert 0 < delta < 65536 - data["summary"] = prefix + "x" * delta - exact = change_request(source, structural_input=data) - content = PostSubmissionEvaluationContent(**exact.model_dump(include=fields)) - assert len(canonical_post_submit_bytes(content)) == POST_SUBMIT_CONTENT_BYTE_LIMIT - maximum = change_request(exact, evaluation_generation=2_147_483_647, - submission_version=2_147_483_647, byte_count=9_223_372_036_854_775_807) - # All remaining fields are fixed-width UUID/hash/schema values. - assert len(canonical_post_submit_bytes(maximum)) - len(canonical_post_submit_bytes(content)) <= 1024 - assert len(canonical_post_submit_bytes(maximum)) <= 1_048_576 - data["summary"] += "x" - with pytest.raises(ValidationError, match="content capacity exceeded"): - change_request(source, structural_input=data) - with pytest.raises(ValidationError, match="content capacity exceeded"): - PostSubmissionEvaluationContent(**{**content.model_dump(), "structural_input": data}) - - -@pytest.mark.parametrize("mapping", (dict, UserDict, MappingProxyType)) -@pytest.mark.parametrize("invalid", (True, 1.0, "1")) -def test_resource_literal_strictness_does_not_depend_on_mapping_type(mapping, invalid): - assert PostSubmitResourceLimits.model_validate(mapping({"maximum_results": 1})).maximum_results == 1 - with pytest.raises(ValidationError, match="limits require integers"): - PostSubmitResourceLimits.model_validate(mapping({"maximum_results": invalid})) - - -@pytest.mark.parametrize("invalid", (True, 1.0, "1")) -def test_resource_literals_revalidate_unchecked_copies(invalid): - unchecked = PostSubmitResourceLimits().model_copy(update={"maximum_results": invalid}) - with pytest.raises(ValidationError, match="limits require integers"): - PostSubmitResourceLimits.model_validate(unchecked) diff --git a/backend/tests/checkers/post_submit/test_public_boundary.py b/backend/tests/checkers/post_submit/test_public_boundary.py index 54f2119bd..20d140b0c 100644 --- a/backend/tests/checkers/post_submit/test_public_boundary.py +++ b/backend/tests/checkers/post_submit/test_public_boundary.py @@ -1,16 +1,14 @@ """New contracts cannot expose persistence, individual dispatch or live execution.""" import ast -import inspect from pathlib import Path -from app.modules.checkers.api import PostSubmissionExecutionPort -from app.modules.checkers.api.post_submit import PostSubmissionEvaluationRequest +from app.modules.checkers.api.selected_post_submit import SelectedPostSubmissionEvaluationRequest def test_public_api_has_no_private_owner_types(): directory = Path(__file__).resolve().parents[3] / "app" / "modules" / "checkers" / "api" - for name in ("post_submit.py", "post_submit_catalogue.py"): + for name in ("selected_post_submit.py", "external.py"): tree = ast.parse((directory / name).read_text()) imports = [node.module or "" for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)] assert not any( @@ -19,12 +17,5 @@ def test_public_api_has_no_private_owner_types(): assert not any( item.startswith(("sqlalchemy", "app.interfaces.project_agents")) for item in imports ) - for cls in (PostSubmissionExecutionPort,): - methods = [ - name - for name, method in inspect.getmembers(cls, inspect.isfunction) - if not name.startswith("_") - ] - assert methods == ["evaluate_post_submission"] - assert "checker_names" not in PostSubmissionEvaluationRequest.model_fields - assert "prepared" not in PostSubmissionEvaluationRequest.model_fields + assert "checker_names" not in SelectedPostSubmissionEvaluationRequest.model_fields + assert "prepared" not in SelectedPostSubmissionEvaluationRequest.model_fields diff --git a/backend/tests/checkers/post_submit/test_request.py b/backend/tests/checkers/post_submit/test_request.py deleted file mode 100644 index bede51a79..000000000 --- a/backend/tests/checkers/post_submit/test_request.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Identity consistency, complete valid denial control, and absence of owner authority.""" - -from app.adapters.checkers import post_submission_executor -from sqlalchemy.ext.asyncio import async_sessionmaker - -from app.core.identifiers import new_record_id - -import pytest -from pydantic import ValidationError - -from app.modules.checkers.api import ( - PostSubmissionEvaluationRequest, - PostSubmissionEvaluationContent, -) -from app.modules.checkers.api.post_submit import make_post_submit_request -from tests.checkers.post_submit.support import OTHER_HASH, change_request, request - - -@pytest.mark.parametrize( - "change,message", - [ - ("project", "project mismatch"), - ("guide", "guide version mismatch"), - ("policy_hash", "policy hash mismatch"), - ("version", "guide and policy versions differ"), - ("digest", "request digest mismatch"), - ], -) -def test_request_digest_and_lineage(change, message): - source = request() - assert PostSubmissionEvaluationRequest.model_validate_json(source.model_dump_json()) == source - fields = source.model_dump(exclude={"request_sha256"}) - if change == "project": - fields["project_id"] = new_record_id() - elif change == "guide": - fields["expected_context"].update(guide_version="other", post_policy_version="other") - elif change == "policy_hash": - fields["expected_context"]["post_policy_hash"] = OTHER_HASH - elif change == "version": - fields["expected_context"]["post_policy_version"] = "other" - with pytest.raises(ValueError, match=message): - if change == "digest": - PostSubmissionEvaluationRequest(**fields, request_sha256=OTHER_HASH) - else: - make_post_submit_request(**fields) - if change != "digest": - with pytest.raises(ValueError, match=message): - PostSubmissionEvaluationContent(**{ - key: fields[key] for key in PostSubmissionEvaluationContent.model_fields - }) - - -def test_coherent_foreign_facts_are_not_an_authorization_proof(): - first = request() - other = request(project_id=new_record_id()) - assert first.project_id != other.project_id - assert PostSubmissionEvaluationRequest.model_validate(other) == other - assert first.request_sha256 != other.request_sha256 - assert not hasattr(other, "authorized") - assert not hasattr(other, "is_current") - - -def test_fact_hashes_are_derived_and_nested_values_are_immutable(): - source = request() - with pytest.raises(ValueError, match="derived"): - make_post_submit_request(**source.model_dump()) - with pytest.raises(ValidationError, match="frozen"): - source.structural_input.manifest[0].artifact = "changed" - dumped = source.model_dump() - dumped["structural_input"]["manifest"][0]["artifact"] = "changed" - assert source.structural_input.manifest[0].artifact == "report.txt" - with pytest.raises(ValidationError, match="digest mismatch"): - PostSubmissionEvaluationRequest.model_validate(dumped) - - -@pytest.mark.parametrize("field", ("submission_version", "evaluation_generation", "byte_count")) -@pytest.mark.parametrize("invalid", (True, -1, "1")) -def test_strict_request_numbers(field, invalid): - with pytest.raises(ValidationError): - change_request(request(), **{field: invalid}) - - -async def test_executor_revalidates_unsafe_constructed_instances(): - source = request() - forged = source.model_copy(update={"project_id": new_record_id()}) - with pytest.raises(ValidationError, match="project mismatch"): - await post_submission_executor(sessions=async_sessionmaker(), materialization=None).evaluate_post_submission(forged) diff --git a/backend/tests/checkers/post_submit/test_requirement_dispositions.py b/backend/tests/checkers/post_submit/test_requirement_dispositions.py index e768db40f..54cde4e8a 100644 --- a/backend/tests/checkers/post_submit/test_requirement_dispositions.py +++ b/backend/tests/checkers/post_submit/test_requirement_dispositions.py @@ -6,33 +6,24 @@ AtomicGuideRequirement, CapabilitySuggestion, PostSubmissionBindingProposal, - CapabilityParameter, - ProjectGuideCompilationContext, ProjectGuideCompilationResult, validate_project_guide_compilation_result, ) -from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitCatalogue, -) from tests.test_project_guide_compilation_contracts import _artifact_policy, _context, _evidence -from tests.checkers.post_submit.support import altered_catalogue, catalogue - - -def context(snapshot=None): - base = _context() - fields = base.model_dump() - fields["post_submission_capabilities"] = PostSubmitCatalogue.model_validate( - (snapshot or catalogue()).model_dump() - ) - return ProjectGuideCompilationContext.model_validate(fields) def proposal(disposition, *, binding=False): gap = disposition == "post_submit_capability_gap" return ProjectGuideCompilationResult( - status="guide_blocked" if gap else "draft_ready", + status="draft_ready_with_warnings" if gap else "draft_ready", agent_version="v1", - submission_artifact_policy=None if gap else _artifact_policy(), + submission_artifact_policy=_artifact_policy(), + findings=({ + "severity": "warning", + "code": "capability.unavailable", + "message": "Required automation is awaiting a registered checker.", + "evidence_refs": (_evidence(),), + },) if gap else (), requirements=( AtomicGuideRequirement( requirement_id="quality", @@ -49,8 +40,8 @@ def proposal(disposition, *, binding=False): post_submit_bindings=( PostSubmissionBindingProposal( requirement_id="quality", - capability_id="check_acceptance_criteria_present", - capability_version="v0.1", + capability_id="acme.post", + capability_version="v1.2.3", stage="post_submit", ), ) @@ -60,7 +51,7 @@ def proposal(disposition, *, binding=False): def test_requirement_disposition_is_preserved(): - source = context() + source = _context() human = proposal("human_review") validate_project_guide_compilation_result(source, human) assert human.requirements[0].disposition.value == "human_review" @@ -69,62 +60,16 @@ def test_requirement_disposition_is_preserved(): validate_project_guide_compilation_result(source, gap) assert gap.requirements[0].disposition.value == "post_submit_capability_gap" assert gap.post_submit_bindings == () - ready_gap = gap.model_copy( - update={"status": "draft_ready", "submission_artifact_policy": _artifact_policy()} + blocked_gap = gap.model_copy( + update={"status": "guide_blocked", "submission_artifact_policy": None} ) - with pytest.raises(ValueError, match="ready compilation cannot contain blocking"): - validate_project_guide_compilation_result(source, ready_gap) + with pytest.raises(ValueError, match="blocked compilation requires blocking"): + validate_project_guide_compilation_result(source, blocked_gap) @pytest.mark.parametrize("disposition", ("human_review", "post_submit_capability_gap")) def test_unbound_dispositions_cannot_acquire_a_binding(disposition): - source = context() + source = _context() validate_project_guide_compilation_result(source, proposal(disposition)) with pytest.raises(ValueError, match="compilation capability binding is invalid"): validate_project_guide_compilation_result(source, proposal(disposition, binding=True)) - - -def test_supported_binding_is_valid_and_roundtrips(): - source = context() - valid = proposal("supported_post_submit", binding=True) - validate_project_guide_compilation_result(source, valid) - assert ProjectGuideCompilationContext.model_validate_json(source.model_dump_json()) == source - - -@pytest.mark.parametrize( - "change,message", - [ - ({"capability_version": "wrong"}, "version is stale"), - ({"capability_id": "unknown"}, "binding is invalid"), - ({"capability_id": "check_submission_packet"}, "binding is invalid"), - ({"stage": "pre_submit"}, "binding is invalid"), - ({"parameters": (CapabilityParameter(name="arbitrary", value="data"),)}, "Extra inputs"), - ({"parameters": (CapabilityParameter(name="supported_claim", value="criteria_presence"),)}, "Extra inputs"), - ], -) -def test_binding_rejects_exact_invalid_property(change, message): - valid = proposal("supported_post_submit", binding=True) - binding = valid.post_submit_bindings[0] - invalid = PostSubmissionBindingProposal(**{**binding.model_dump(), **change}) - with pytest.raises(ValueError, match=message): - validate_project_guide_compilation_result( - context(), valid.model_copy(update={"post_submit_bindings": (invalid,)}) - ) - - -def test_disabled_selection_stays_a_gap_and_cannot_bind(): - source = context(altered_catalogue(state="disabled")) - gap = proposal("post_submit_capability_gap") - validate_project_guide_compilation_result(source, gap) - assert gap.requirements[0].disposition.value == "post_submit_capability_gap" - with pytest.raises(ValueError, match="definition is unavailable"): - validate_project_guide_compilation_result( - source, proposal("supported_post_submit", binding=True) - ) - - -def test_disabled_mandatory_catalogue_cannot_produce_a_ready_guide(): - with pytest.raises(ValueError, match="post-submit capability projection is unavailable"): - validate_project_guide_compilation_result( - context(altered_catalogue(index=0, state="disabled")), proposal("human_review") - ) diff --git a/backend/tests/checkers/post_submit/test_result_contract.py b/backend/tests/checkers/post_submit/test_result_contract.py deleted file mode 100644 index 3528c968c..000000000 --- a/backend/tests/checkers/post_submit/test_result_contract.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Phase/result reference identity and closed failure semantics without persistence.""" - -from app.core.identifiers import new_record_id - -import pytest -from pydantic import ValidationError - -from app.modules.checkers.api import ( - PostSubmissionEvaluationResult, - PostSubmitCurrentResultReference, - PostSubmitMemberResult, -) -from app.modules.checkers.api.post_submit import PostSubmitCounter -from app.modules.checkers.post_submit_contracts import make_post_submit_result -from tests.checkers.post_submit.support import OTHER_HASH, request - - -def result(source, **changes): - fields = dict( - request_id=source.evaluation_request_id, - request_digest=source.request_sha256, - attempt_id=new_record_id(), - evaluation_generation=source.evaluation_generation, - result_id=new_record_id(), - outcome="completed", - member_results=tuple( - PostSubmitMemberResult( - checker_id=item.checker_id, - definition_version=item.definition_version, - implementation_version=item.implementation_version, - status="passed", - code="passed", - failure_category="none", - severity="info", - ) - for item in source.policy.entries - ), - ) - fields.update(changes) - return make_post_submit_result(**fields) - - -def test_result_is_not_currentness_or_acceptance(): - source = request() - completed = result(source) - completed.validate_request(source) - fields = { - name: getattr(completed, name) - for name in PostSubmitCurrentResultReference.model_fields - if name != "schema_version" - } - reference = PostSubmitCurrentResultReference(**fields) - reference.validate_result(completed) - assert not hasattr(reference, "is_current") - assert not hasattr(completed, "accept") - assert ( - PostSubmissionEvaluationResult.model_validate_json(completed.model_dump_json()) == completed - ) - - -@pytest.mark.parametrize("field", ("request_id", "request_digest", "evaluation_generation")) -def test_result_rejects_crossed_request(field): - source = request() - value = ( - OTHER_HASH if field.endswith("digest") else 2 if field.endswith("generation") else new_record_id() - ) - with pytest.raises(ValueError, match="request mismatch"): - result(source, **{field: value}).validate_request(source) - - -@pytest.mark.parametrize("damage", ("omit", "reorder", "version", "claim")) -def test_completed_member_list_matches_plan_exactly(damage): - source = request() - members = list(result(source).member_results) - if damage == "omit": - members.pop() - elif damage == "reorder": - members.reverse() - elif damage == "version": - members[0] = members[0].model_copy(update={"implementation_version": "other"}) - else: - members[0] = PostSubmitMemberResult( - **{ - **members[0].model_dump(), - "status": "failed", - "code": "evidence_missing", - "failure_category": "submission_structure", - "severity": "high", - } - ) - with pytest.raises(ValueError, match="members mismatch|registered claim"): - result(source, member_results=tuple(members)).validate_request(source) - - -@pytest.mark.parametrize( - "code", - ("capacity_exceeded", "deadline_exceeded", "material_unavailable", "implementation_unavailable", "invalid_output"), -) -def test_infrastructure_failure_never_carries_work_failure(code): - source = request() - failed = result( - source, outcome="infrastructure_failed", member_results=(), infrastructure_failure_code=code - ) - failed.validate_request(source) - assert failed.member_results == () - fields = { - key: getattr(failed, key) - for key in PostSubmitCurrentResultReference.model_fields - if key != "schema_version" - } - with pytest.raises(ValueError, match="current reference result mismatch"): - PostSubmitCurrentResultReference(**fields).validate_result(failed) - - -@pytest.mark.parametrize( - "changes", - [ - {"member_results": ()}, - {"infrastructure_failure_code": "deadline_exceeded"}, - {"outcome": "infrastructure_failed"}, - {"outcome": "accept"}, - {"outcome": "infrastructure_failed", "member_results": ()}, - ], -) -def test_phase_shapes_reject_conflicting_or_empty_outcomes(changes): - with pytest.raises(ValueError): - result(request(), **changes) - - -def test_result_digest_and_reference_require_exact_identity(): - source = request() - complete = result(source) - body = complete.model_dump() - body["result_digest"] = OTHER_HASH - with pytest.raises(ValueError, match="result digest mismatch"): - PostSubmissionEvaluationResult.model_validate(body) - with pytest.raises(ValueError, match="derived"): - make_post_submit_result(**complete.model_dump()) - fields = { - key: getattr(complete, key) - for key in PostSubmitCurrentResultReference.model_fields - if key != "schema_version" - } - fields["attempt_id"] = new_record_id() - with pytest.raises(ValueError, match="current reference result mismatch"): - PostSubmitCurrentResultReference(**fields).validate_result(complete) - - -@pytest.mark.parametrize( - "changes", - [ - {"severity": "high"}, - {"code": "packet_fields_missing"}, - {"failure_category": "task_configuration"}, - {"status": "failed"}, - {"message": "private/path"}, - {"counters": ({"key": "artifact_count", "value": True},)}, - {"counters": ({"key": "raw_path", "value": 1},)}, - {"counters": ({"key": "artifact_count", "value": 1},) * 2}, - ], -) -def test_member_result_shape_is_closed(changes): - passing = result(request()).member_results[0] - with pytest.raises(ValidationError): - PostSubmitMemberResult(**{**passing.model_dump(), **changes}) - - -def test_registered_failure_and_result_counter_boundaries(): - source = request() - definition = source.catalogue.definitions[0] - member = PostSubmitMemberResult( - checker_id=definition.capability_id, - implementation_version=definition.implementation_version, - status="failed", - code="packet_fields_missing", - failure_category="submission_structure", - severity="high", - counters=(PostSubmitCounter(key="missing_count", value=1024),), - ) - member.validate_catalogue(source.catalogue) - assert PostSubmitCounter(key="missing_count", value=0).value == 0 - with pytest.raises(ValidationError): - PostSubmitCounter(key="missing_count", value=1025) - with pytest.raises(ValueError, match="implementation mismatch"): - member.model_copy(update={"implementation_version": "other"}).validate_catalogue( - source.catalogue - ) - - -def test_consumers_revalidate_unchecked_copies(): - source = request() - complete = result(source) - forged = complete.model_copy(update={"result_digest": OTHER_HASH}) - with pytest.raises(ValidationError, match="result digest mismatch"): - forged.validate_request(source) - forged_member = complete.member_results[0].model_copy(update={"severity": "high"}) - with pytest.raises(ValidationError, match="passing result shape"): - forged_member.validate_catalogue(source.catalogue) - - -def test_closed_result_shape_stays_below_declared_byte_ceilings(): - from app.modules.checkers.api.post_submit_catalogue import ( - PostSubmitResourceLimits, canonical_post_submit_bytes, - ) - - # Longest allowed ASCII identities, closed tokens, all four counters at their - # maximum, and nine members bound the complete JSON shape without free text. - member = PostSubmitMemberResult( - checker_id="x" * 100, - implementation_version="x" * 100, - status="warning", - code="acceptance_criteria_missing", - failure_category="submission_structure", - severity="critical", - counters=tuple(PostSubmitCounter(key=key, value=1024) for key in ( - "artifact_count", "missing_count", "invalid_count", "matched_count", - )), - ) - phase = result(request(), member_results=(member,) * 9, evaluation_generation=2_147_483_647) - assert len(canonical_post_submit_bytes(member)) == 604 - assert len(canonical_post_submit_bytes(phase)) == 5942 - assert len(canonical_post_submit_bytes(member)) < PostSubmitResourceLimits().maximum_result_bytes - assert len(canonical_post_submit_bytes(phase)) < 65536 - with pytest.raises(ValidationError, match="100"): - PostSubmitMemberResult(**{**member.model_dump(), "checker_id": "x" * 101}) - with pytest.raises(ValidationError, match="at most 9"): - result(request(), member_results=(member,) * 10) diff --git a/backend/tests/checkers/test_effective_intake_rules.py b/backend/tests/checkers/test_effective_intake_rules.py deleted file mode 100644 index 7af6dfcf1..000000000 --- a/backend/tests/checkers/test_effective_intake_rules.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Project intake rules over actual prepared ZIP bytes, not caller packet manifests. - -AUTH is an explicit port double here; canonical authority has separate tests. -This module proves materialization outcomes, not durable admission or Submission creation. -""" - -from dataclasses import replace -from types import SimpleNamespace -from tests.pre_submit_test_helpers import materialize_member_fixture - - -import pytest - -from app.core.hashing import canonical_json_hash -from app.modules.artifacts.submission_materialization import PreparedBundleMaterializationService -from app.modules.checkers.compiler import compile_effective_project_submission_artifact_policy -from app.modules.checkers.effective_plan import compile_effective_pre_submission_execution_plan -from app.modules.checkers.pre_submit_execution import PreSubmissionResultStatus -from tests.test_default_pre_submit_execution import ( - _AllowAuthority, _CheckerExecution, _effective_policy, _request, -) - - -@pytest.mark.parametrize(("case", "failed_definition"), [ - ("complete", None), - ("missing_file", "policy.file.require"), - ("project_evidence", "policy.evidence.minimum"), - ("project_attestation", "policy.attestation.require"), - ("project_forbidden_file", "policy.artifact.forbid"), -]) -async def test_effective_project_rules_control_exact_prepared_contents( - tmp_path, case, failed_definition, -): - request, inspector, manager, preparation, catalogue = await _request( - tmp_path, - path="unexpected.txt" if case == "missing_file" else "task.toml", - extra_path="project-private.data" if case == "project_forbidden_file" else None, - ) - try: - policy = _effective_policy() - if case == "project_evidence": - addition = {"key": "build_log", "required": True} - policy["project_policy"] = {"required_evidence": [addition]} - policy["required_evidence"] = [*policy["required_evidence"], addition] - elif case == "project_attestation": - policy["project_policy"] = {"attestation_terms": ["project_confidentiality_confirmed"]} - policy["attestation_terms"] = [*policy["attestation_terms"], "project_confidentiality_confirmed"] - elif case == "project_forbidden_file": - addition = {"pattern": "project-private.data"} - policy["project_policy"] = {"forbidden_artifacts": [addition]} - policy["forbidden_artifacts"] = [*policy["forbidden_artifacts"], addition] - policy_hash = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, policy_hash) - plan = compile_effective_pre_submission_execution_plan( - lineage=replace( - request.effective_plan.lineage, effective_policy_hash=policy_hash, - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, - ), - effective_policy=policy, compiled_bundle=compiled.compiled_bundle, catalogue=catalogue, - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", - ) - result = await materialize_member_fixture(service, replace(request, effective_plan=plan)) - assert result.eligible is (failed_definition is None) - failed = [entry.definition_id for entry in result.entries - if entry.checker_execution_status == PreSubmissionResultStatus.FAILED.value] - assert failed == ([] if failed_definition is None else [failed_definition]) - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - finally: - await request.prepared_artifact.close() - manager.close() diff --git a/backend/tests/checkers/test_phase_service.py b/backend/tests/checkers/test_phase_service.py deleted file mode 100644 index fe3dfdf19..000000000 --- a/backend/tests/checkers/test_phase_service.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Phase delegation and removed JSON precheck; no fake post execution custody.""" - -from app.modules.checkers.api.execution import CheckerExecutionUnavailable - -from tests.checkers.execution.support import forbidden_post_submission - -import inspect -from types import SimpleNamespace -from unittest.mock import AsyncMock -from app.core.identifiers import new_record_id - -import pytest -from pydantic import ValidationError - -from app.adapters.artifacts import CheckerPhaseService -from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidencePersistenceResult -from tests.checkers.post_submit.support import OTHER_HASH, request -from tests.checkers.post_submit.test_result_contract import result - - -def phases(pre=None, post=None): - return CheckerPhaseService( - pre_submission=pre if pre is not None else SimpleNamespace(execute_reserved=AsyncMock()), - post_submission=post if post is not None else forbidden_post_submission(), - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("replay", (False, True)) -async def test_pre_phase_preserves_owner_result_and_pass_capability(replay): - capability = None if replay else object() - canonical = PreSubmitEvidencePersistenceResult( - evidence=object(), execution=object(), pass_capability=capability, failure_audit=None, - ) - owner = SimpleNamespace(execute_reserved=AsyncMock(return_value=canonical)) - material = SimpleNamespace(prepared_authorization=None) - preparation = object() - selection = canonical if replay else object() - actual = await phases(pre=owner).evaluate_pre_submission( - material, selection, preparation_request=preparation, - ) - assert actual is canonical - assert actual.pass_capability is capability - if replay: - owner.execute_reserved.assert_not_awaited() - else: - owner.execute_reserved.assert_awaited_once_with( - material, selection, preparation_request=preparation, - ) - - -@pytest.mark.asyncio -async def test_pre_phase_rejects_authorization_handle_and_propagates_owner_failure(): - owner = SimpleNamespace(execute_reserved=AsyncMock(side_effect=RuntimeError("unresolved"))) - service = phases(pre=owner) - with pytest.raises(ValueError, match="requires_consumed_authorization"): - await service.evaluate_pre_submission( - SimpleNamespace(prepared_authorization=object()), object(), preparation_request=object(), - ) - owner.execute_reserved.assert_not_awaited() - with pytest.raises(RuntimeError, match="^unresolved$"): - await service.evaluate_pre_submission( - SimpleNamespace(prepared_authorization=None), object(), preparation_request=object(), - ) - owner.execute_reserved.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_post_phase_propagates_owner_denial_without_invoking_pre_owner(): - owner = SimpleNamespace(execute_reserved=AsyncMock()) - executor = SimpleNamespace(evaluate_post_submission=AsyncMock( - side_effect=CheckerExecutionUnavailable("post_submit_execution_unavailable"), - )) - source = request() - with pytest.raises(CheckerExecutionUnavailable, match="^post_submit_execution_unavailable$"): - await phases(pre=owner, post=executor).evaluate_post_submission(source) - executor.evaluate_post_submission.assert_awaited_once_with(source) - owner.execute_reserved.assert_not_awaited() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("damage", (None, "request", "digest", "generation", "members")) -async def test_post_phase_validates_exact_delegated_result(damage): - source = request() - changes = {} - if damage == "request": - changes["request_id"] = new_record_id() - elif damage == "digest": - changes["request_digest"] = OTHER_HASH - elif damage == "generation": - changes["evaluation_generation"] = 2 - elif damage == "members": - changes["member_results"] = result(source).member_results[:-1] - completed = result(source, **changes) - executor = SimpleNamespace(evaluate_post_submission=AsyncMock(return_value=completed)) - service = phases(post=executor) - if damage is None: - assert await service.evaluate_post_submission(source) == completed - else: - with pytest.raises(ValueError, match="post-submit result .* mismatch"): - await service.evaluate_post_submission(source) - executor.evaluate_post_submission.assert_awaited_once_with(source) - - -@pytest.mark.asyncio -async def test_invalid_post_request_never_reaches_executor(): - executor = SimpleNamespace(evaluate_post_submission=AsyncMock()) - source = request().model_copy(update={"project_id": new_record_id()}) - with pytest.raises(ValidationError, match="project mismatch"): - await phases(post=executor).evaluate_post_submission(source) - executor.evaluate_post_submission.assert_not_awaited() - - -def test_two_phase_commands_and_no_draft_packet_precheck_route(): - from app.main import create_app - from app.modules.checkers import runner - - assert [name for name, method in inspect.getmembers(CheckerPhaseService, inspect.isfunction) - if not name.startswith("_")] == ["evaluate_post_submission", "evaluate_pre_submission"] - app = create_app() - assert all("submission-precheck" not in getattr(route, "path", "") for route in app.routes) - schema = app.openapi() - assert all("submission-precheck" not in path for path in schema["paths"]) - for name in ("PreSubmitCheckRequest", "PreSubmitCheckResponse", "CheckerFeedbackItem"): - assert name not in schema["components"]["schemas"] - assert not hasattr(runner, "pre_submit_static_feedback") diff --git a/backend/tests/committed_guide_fixtures.py b/backend/tests/committed_guide_fixtures.py index 315b9a7b1..3b1ba9cb4 100644 --- a/backend/tests/committed_guide_fixtures.py +++ b/backend/tests/committed_guide_fixtures.py @@ -90,12 +90,11 @@ async def seed_setup_service_for_compiled_fixture(sessions): async def create_compiled_report_fixture( - report_id: str, source_snapshot_id: str, *, artifact_proposal=None, post_submit_required_checkers=(), + report_id: str, source_snapshot_id: str, *, artifact_proposal=None, post_submit_registry_spec=None, ) -> str: """Project a scripted unified result under real service authority and custody.""" from app.adapters.auth import guide_compilation_request_authority, guide_compilation_execution_authority, guide_sufficiency_projection_authorization, artifact_policy_projection_authorization - from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue, project_guide_pre_submission_capabilities - from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue + from app.adapters.checkers import external_checker_capability_projection from app.modules.projects.api import ProjectGuideCompilationExecutionCommand, ProjectGuideProjectionCommand, ProjectGuideCompilationExecutionClassification from app.modules.projects.api.setup_identity import project_guide_compilation_task_id from app.modules.projects.guide_compilation.request_inputs import ( @@ -108,6 +107,12 @@ async def create_compiled_report_fixture( from app.interfaces.project_agents import CompilationFinding, GuideEvidenceRef sessions = db_session.get_session_factory() + from tests.projects.guide_activation.source_fixtures import _register_external_checker_fixture + + post_entry = ( + await _register_external_checker_fixture(sessions, post_submit_registry_spec) + if post_submit_registry_spec is not None else None + ) await seed_setup_service_for_compiled_fixture(sessions) async with sessions() as session: report = await session.scalar(select(GuideSufficiencyReport).where(GuideSufficiencyReport.source_snapshot_id == source_snapshot_id, GuideSufficiencyReport.project_setup_run_id.is_not(None))) @@ -118,8 +123,8 @@ async def create_compiled_report_fixture( status, findings = diagnostic.status, diagnostic.findings manifest = await create_committed_document_fixture(source_snapshot_id) configuration = runtime_configuration() - pre = project_guide_pre_submission_capabilities(build_pre_submission_checker_catalogue()) - post = current_post_submit_catalogue() + async with sessions() as session, session.begin(): + capabilities = await external_checker_capability_projection(session) async with sessions() as session: setup = await session.get(ProjectSetupRun, str(manifest.setup_run_id)) setup.status = setup.current_step = "queued" @@ -130,7 +135,13 @@ async def create_compiled_report_fixture( session, automatic_request_selector(manifest.setup_run_id, manifest.setup_generation), ) as (authority, actor): - request = await GuideCompilationService(session, authority, request_inputs=CompilationRequestInputs(guide_document_manifest_port(session), pre, post, configuration)).request_automatic(actor=actor, setup_run_id=manifest.setup_run_id) + request = await GuideCompilationService( + session, + authority, + request_inputs=CompilationRequestInputs( + guide_document_manifest_port(session), capabilities, configuration + ), + ).request_automatic(actor=actor, setup_run_id=manifest.setup_run_id) class Runtime: identity = configuration.adapter_identity @@ -150,20 +161,23 @@ async def compile_project_guide(self, context, capabilities): patch = {"status": {"blocked": "guide_blocked", "passed": "draft_ready", "passed_with_warnings": "draft_ready_with_warnings"}[status], "findings": compiled_findings} if artifact_proposal is not None: patch["submission_artifact_policy"] = artifact_proposal - from app.interfaces.project_agents import AtomicGuideRequirement, PostSubmissionBindingProposal - patch["requirements"] = tuple(AtomicGuideRequirement( - requirement_id=f"post_requirement_{index}", statement="Apply the selected task evaluation requirement", - disposition="supported_post_submit", evidence_refs=refs, - ) for index, _ in enumerate(post_submit_required_checkers)) - patch["post_submit_bindings"] = tuple(PostSubmissionBindingProposal( - requirement_id=f"post_requirement_{index}", capability_id=checker, - capability_version="v0.1", stage="post_submit", parameters=(), - ) for index, checker in enumerate(post_submit_required_checkers)) if status == "blocked": patch["submission_artifact_policy"] = None - return result().model_copy(update=patch) - - execution = project_guide_compilation_execution_port(sessions, material_factory=guide_document_manifest_port, document_access_factory=document_access, pre_submission_capabilities=pre, post_submission_capabilities=post, authorization_context=guide_compilation_execution_authority, runtime_factory=lambda config: Runtime()) + from tests.projects.guide_activation.source_fixtures import _external_compilation_outcome + + return _external_compilation_outcome( + result().model_copy(update=patch), + (post_entry,) if post_entry is not None else (), None, post_entry, refs, + ) + + execution = project_guide_compilation_execution_port( + sessions, + material_factory=guide_document_manifest_port, + document_access_factory=document_access, + checker_capabilities=capabilities, + authorization_context=guide_compilation_execution_authority, + runtime_factory=lambda config: Runtime(), + ) receipt = await execution.execute(ProjectGuideCompilationExecutionCommand(attempt_id=request.attempt_id)) assert receipt.classification is ProjectGuideCompilationExecutionClassification.PERSISTED projections = GuideCompilationProjectionService(sessions, material_factory=guide_document_manifest_port, sufficiency_authorization_factory=guide_sufficiency_projection_authorization, policy_authorization_factory=artifact_policy_projection_authorization) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 30d13d79b..c01f1b6f4 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 = "7c6bcebf14e5733d14a455a7a94d271205bfa6c2c3e7d6d5991148b3712025c2" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "f510310623f222209cbbee89291edb8e1582d65ddaf9d173d534dd796ab68991" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -125,6 +125,7 @@ "review_queue_entries", "submission_policy_mutation_idempotency_records", "submission_artifact_policies", + "selected_pre_submit_runs", "submission_bundle_admissions", "submission_dispatches", "submission_binding_receipts", @@ -210,6 +211,7 @@ "review_queue_entries", "review_policies", "revision_policies", + "selected_pre_submit_runs", "submission_bundle_admissions", "submission_dispatches", "submission_binding_receipts", diff --git a/backend/tests/contributions/records/historical_support.py b/backend/tests/contributions/records/historical_support.py index 1776467e8..b33f62180 100644 --- a/backend/tests/contributions/records/historical_support.py +++ b/backend/tests/contributions/records/historical_support.py @@ -4,6 +4,7 @@ from uuid import UUID from sqlalchemy import text from app.core.identifiers import new_record_id +from app.db import session as db_session from app.modules.contributions.records.schemas import ContributionRecordInput from tests.reviews.acceptance.historical_support import ( historical_acceptance_source, @@ -18,21 +19,25 @@ async def historical_contribution_source( *, decision="accept", paid=False, - contribution_awards=None, persist_acceptance=True, - **options, ): - frozen_awards = ( - tuple(contribution_awards) - if contribution_awards is not None - else (("money", "project_points") if paid else ()) - ) + if type(paid) is not bool: + raise ValueError("Archived contribution paid selection must be boolean") + snapshot_name = None + if paid: + async with db_session.get_session_factory()() as session: + revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) + if revision not in { + "0015_contribution_awards", "0018_task_routing_request", + "0024_require_second_review_false", + }: + raise ValueError("No archived paid contribution snapshot for this revision") + snapshot_name = revision + "_paid" async with historical_acceptance_source( tmp_path, url, decision=decision, - contribution_awards=frozen_awards, - **options, + snapshot_name=snapshot_name, ) as h: async with h.factory() as session: if decision == "accept" and persist_acceptance: @@ -45,28 +50,28 @@ async def historical_contribution_source( SELECT task_assignment_id, contribution_policy_version_id, contributor_id FROM public.submissions WHERE id=:id """), - {"id": h.review.submission_id}, + {"id": h.review["submission_id"]}, ) ) .mappings() .one() ) common = dict( - project_id=h.review.project_id, - task_id=h.review.task_id, - submission_id=h.review.submission_id, - artifact_hash=h.review.artifact_hash, + project_id=h.review["project_id"], + task_id=h.review["task_id"], + submission_id=h.review["submission_id"], + artifact_hash=h.review["artifact_hash"], ) h.reviewer_record = ContributionRecordInput( **common, id=new_record_id(), contribution_type="completed_review", - contributor_id=h.review.reviewer_id, - source_review_id=h.review.id, - source_review_lease_id=h.review.review_lease_id, + contributor_id=h.review["reviewer_id"], + source_review_id=h.review["id"], + source_review_lease_id=h.review["review_lease_id"], source_final_acceptance_id=None, source_task_assignment_id=None, - contribution_policy_version_id=h.review.reviewer_contribution_policy_version_id, + contribution_policy_version_id=h.review["reviewer_contribution_policy_version_id"], ) h.submitter_record = ContributionRecordInput( **common, @@ -75,7 +80,7 @@ async def historical_contribution_source( contributor_id=UUID(str(row["contributor_id"])), source_review_id=None, source_review_lease_id=None, - source_final_acceptance_id=h.acceptance.id, + source_final_acceptance_id=h.acceptance["id"], source_task_assignment_id=UUID(str(row["task_assignment_id"])), contribution_policy_version_id=row["contribution_policy_version_id"], ) diff --git a/backend/tests/contributions/records/test_migration.py b/backend/tests/contributions/records/test_migration.py index d4c32a879..e82b9532e 100644 --- a/backend/tests/contributions/records/test_migration.py +++ b/backend/tests/contributions/records/test_migration.py @@ -10,14 +10,12 @@ from alembic import command from sqlalchemy.exc import DBAPIError -from tests.checkers.execution.historical_execution import historical_completed_source from app.db import session as db_session from tests.contributions.records.support import ( award_values, insert_award, insert_record, ) -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config from tests.reviews.acceptance.historical_support import ( historical_acceptance_source, @@ -61,11 +59,9 @@ async def test_contribution_upgrade_preserves_sources( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0014_final_acceptance") - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_acceptance_source( - tmp_path, isolated_database_env, completed_source_factory=historical_completed_source + tmp_path, isolated_database_env ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) async with h.factory() as session: await insert_historical_acceptance(session, h.acceptance) await session.commit() @@ -125,14 +121,11 @@ async def test_completeness_upgrade_preserves_complete_retained_awards( ): with migration_lock(): await _reset_to_0018(isolated_database_env) - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_contribution_source( tmp_path, isolated_database_env, paid=True, - completed_source_factory=historical_completed_source, ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) async with h.factory() as session: await insert_record(session, h.submitter_record) for award in await award_values(session, h.submitter_record): @@ -202,14 +195,11 @@ async def test_completeness_upgrade_refuses_incomplete_retained_awards_unchanged ): with migration_lock(): await _reset_to_0018(isolated_database_env) - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_contribution_source( tmp_path, isolated_database_env, paid=True, - completed_source_factory=historical_completed_source, ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) async with h.factory() as session: await insert_record(session, h.submitter_record) await insert_award( diff --git a/backend/tests/fixtures/pilot04_predecessor_rows.json.gz b/backend/tests/fixtures/pilot04_predecessor_rows.json.gz new file mode 100644 index 000000000..997369793 Binary files /dev/null and b/backend/tests/fixtures/pilot04_predecessor_rows.json.gz differ diff --git a/backend/tests/historical_submission_fixtures.py b/backend/tests/historical_submission_fixtures.py index 0bd6c7db6..3fcac7710 100644 --- a/backend/tests/historical_submission_fixtures.py +++ b/backend/tests/historical_submission_fixtures.py @@ -1,111 +1,316 @@ -"""Explicit predecessor storage for migration proofs, never a current intake writer. +"""Checksum-bound predecessor rows for migration tests only. -Only migration tests call this seeder. It writes the pre-0025 row shape with all -then-installed constraints enabled; it does not invent current AUTH or dispatch -receipts. Preparation and verification still use real ART owners and ZIP bytes. +The archive projects one-time predecessor captures to their declared schemas. +Snapshots from 0010 through 0024 also include the exact project-neutral POST +service actor and link captured by the same writer in the 0034 source snapshot; +the archive provenance names those two supplemental row IDs. It contains data, +not an executable checker implementation. Restore is confined to an isolated +predecessor database and finishes with every trigger and foreign key setting +restored before a migration test receives the fixture. """ -from contextlib import AsyncExitStack, asynccontextmanager +from __future__ import annotations + +from contextlib import asynccontextmanager +from copy import deepcopy +from functools import lru_cache +import gzip +import hashlib +import json +from pathlib import Path from types import SimpleNamespace -from uuid import UUID - -from sqlalchemy import select, func - -from app.core.identifiers import new_record_id -from app.modules.artifacts.models import ArtifactBinding, SubmissionBundleAdmission -from app.modules.checkers.api.post_submit import make_post_submit_request -from app.modules.tasks.api import TaskSubmissionContextRequest -from app.modules.tasks.models import Submission -from app.modules.tasks.repository import TaskRepository -from app.modules.tasks.submission_composition import build_submission -from tests.post_submit_materialization_helpers import _material_fixture, _archive_facts -from tests.migration_fixtures import current_art_attempt_seed_schema - - -async def write_historical_submission(factory, context, request): - """Seed only an actual predecessor schema; never bypass current constraints.""" - from sqlalchemy import text - async with factory() as session, session.begin(): - revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) - assert revision in { - "0007_checker_output_custody", "0008_checker_execution", "0009_checker_material_lineage", - "0010_post_submit_authority", "0011_task_routing_source", "0012_review_packet", - "0013_review_source", "0014_final_acceptance", "0015_contribution_awards", - "0017_acceptance_source_contracts", "0018_task_routing_request", - "0020_review_admission_lock_order", "0021_submission_manifest", - "0022_submission_packet_custody", "0023_remove_task_payment_policy", - "0024_require_second_review_false", - }, "historical seeder cannot write the current schema" - repository = TaskRepository(session) - facts, source = await repository.select_submission_context(TaskSubmissionContextRequest( - request.task_id, request.assignment_id, request.contributor_id, request.predecessor_submission_id, - )) - assert source is None - task = await repository.get_task(str(request.task_id)) - admission = await session.get(SubmissionBundleAdmission, str(request.admission_id)) - assert admission.status == "ready" and admission.actor_profile_id == str(context.actor_profile_id) - version = 1 if facts.predecessor is None else facts.predecessor.version + 1 - submission_id, binding_id = new_record_id(), new_record_id() - submission = build_submission( - submission_id=str(submission_id), task=task, contributor_id=str(request.contributor_id), - version=version, summary=request.summary, worker_attestation=request.contributor_attestation, - supersedes_submission_id=str(request.predecessor_submission_id) if request.predecessor_submission_id else None, - contribution_policy_version_id=facts.submitter_contribution_policy_version_id, - task_assignment_id=str(request.assignment_id), - ) - session.add(submission) - await session.flush() - session.add(ArtifactBinding( - id=str(binding_id), content_id=admission.artifact_content_id, project_id=admission.project_id, - resource_type="submission", resource_id=str(submission_id), logical_role="submission_bundle_original", - scope_version=1, actor_id=admission.actor_profile_id, attribution_type="contributor", - )) - consumed_at = await session.scalar(select(func.now())) - admission.status = "consumed" - admission.consumed_at = consumed_at - admission.consumed_by_submission_id = str(submission_id) - admission.consumed_by_submission_version = version - submission.submission_bundle_admission_id = admission.id - submission.artifact_binding_id = str(binding_id) - submission.artifact_content_id = admission.artifact_content_id - task.status = "submitted" - await session.flush() - return SimpleNamespace(submission_id=submission_id, submission_version=version, - admission_id=request.admission_id, artifact_binding_id=binding_id, - artifact_content_id=UUID(admission.artifact_content_id), task_context=facts) - - -async def _historical_request(session, facts, created, data): - """Project genuine historical inputs without creating a future reservation.""" - from app.adapters.tasks import task_service - from app.adapters.checkers import submission_evaluation_content - from app.core.config import get_settings - from app.modules.checkers.api import SubmissionPacketView - from app.modules.tasks.models import WorkstreamTask - submission = await session.get(Submission, str(created.submission_id)) - task = await session.get(WorkstreamTask, submission.task_id) - project = await task_service(session, settings=get_settings())._load_locked_task_context(task) - _, manifest, _, digest = _archive_facts(data) - content = submission_evaluation_content( - created.task_context, project.facts, - SubmissionPacketView(submission.summary, submission.worker_attestation), - manifest.file_facts(), digest, + +import asyncpg + +from app.core.hashing import canonical_json_hash +from app.db import session as db_session +from conftest import _assert_owned_test_database + + +_ARCHIVE = Path(__file__).with_name("fixtures") / "pilot04_predecessor_rows.json.gz" +_ARCHIVE_SHA256 = "e24ab79e764452f92d41bf93ecb00c6a9e08fae427145c51a3e55160108ed054" +_SCHEMA_VERSION = "workstream.pilot04_predecessor_rows.v1" + + +def _asyncpg_url(database_url: str) -> str: + return "postgresql" + database_url.removeprefix("postgresql+asyncpg") + + +def _identifier(value: str) -> str: + return '"' + value.replace('"', '""') + '"' + + +@lru_cache(maxsize=1) +def _snapshots() -> dict[str, dict[str, object]]: + encoded = _ARCHIVE.read_bytes() + assert hashlib.sha256(encoded).hexdigest() == _ARCHIVE_SHA256, ( + "PILOT-04 predecessor row archive checksum mismatch" ) - return make_post_submit_request( - **content.model_dump(), evaluation_request_id=new_record_id(), evaluation_generation=1, - task_id=facts.task_id, assignment_id=facts.assignment_id, - submission_id=created.submission_id, submission_version=created.submission_version, - content_id=created.artifact_content_id, binding_id=created.artifact_binding_id, - content_sha256=digest, byte_count=len(data), + body = json.loads(gzip.decompress(encoded)) + assert body["schema_version"] == _SCHEMA_VERSION + return body["snapshots"] + + +async def _trigger_state(connection: asyncpg.Connection) -> tuple[tuple[object, ...], ...]: + rows = await connection.fetch(""" + SELECT n.nspname, c.relname, t.tgname, t.tgenabled + 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' + ORDER BY c.relname,t.tgname + """) + return tuple(tuple(row) for row in rows) + + +async def _assert_database_guards_enabled(connection: asyncpg.Connection) -> None: + assert await connection.fetchval("SHOW session_replication_role") == "origin" + assert await connection.fetchval(""" + 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 NOT IN ('O','A') + """) == 0 + + +async def _foreign_keys(connection: asyncpg.Connection) -> tuple[asyncpg.Record, ...]: + return tuple(await connection.fetch(""" + SELECT con.oid, con.conname, child.relname AS child_table, + parent.relname AS parent_table, con.condeferrable, con.condeferred, + con.confmatchtype, + array_agg(child_col.attname ORDER BY keys.ordinality) AS child_columns, + array_agg(parent_col.attname ORDER BY keys.ordinality) AS parent_columns + FROM pg_constraint con + JOIN pg_class child ON child.oid=con.conrelid + JOIN pg_class parent ON parent.oid=con.confrelid + JOIN pg_namespace n ON n.oid=con.connamespace + CROSS JOIN LATERAL unnest(con.conkey,con.confkey) + WITH ORDINALITY AS keys(child_num,parent_num,ordinality) + JOIN pg_attribute child_col + ON child_col.attrelid=con.conrelid AND child_col.attnum=keys.child_num + JOIN pg_attribute parent_col + ON parent_col.attrelid=con.confrelid AND parent_col.attnum=keys.parent_num + WHERE n.nspname='public' AND con.contype='f' + GROUP BY con.oid,con.conname,child.relname,parent.relname, + con.condeferrable,con.condeferred,con.confmatchtype + ORDER BY child.relname,con.conname + """)) + + +async def _validate_foreign_keys( + connection: asyncpg.Connection, foreign_keys: tuple[asyncpg.Record, ...] +) -> None: + """Independently reject every orphan or partial MATCH FULL archived row.""" + for constraint in foreign_keys: + child = _identifier(constraint["child_table"]) + parent = _identifier(constraint["parent_table"]) + child_columns = tuple(constraint["child_columns"]) + parent_columns = tuple(constraint["parent_columns"]) + all_present = " AND ".join( + f"c.{_identifier(column)} IS NOT NULL" for column in child_columns + ) + any_present = " OR ".join( + f"c.{_identifier(column)} IS NOT NULL" for column in child_columns + ) + any_missing = " OR ".join( + f"c.{_identifier(column)} IS NULL" for column in child_columns + ) + equal = " AND ".join( + f"p.{_identifier(parent_column)} IS NOT DISTINCT FROM " + f"c.{_identifier(child_column)}" + for child_column, parent_column in zip( + child_columns, parent_columns, strict=True + ) + ) + invalid_full = ( + f" OR (({any_present}) AND ({any_missing}))" + if constraint["confmatchtype"] == "f" + else "" + ) + count = await connection.fetchval( + f"""SELECT count(*) FROM public.{child} c + WHERE (({all_present}) AND NOT EXISTS ( + SELECT 1 FROM public.{parent} p WHERE {equal} + )){invalid_full}""" + ) + assert count == 0, f"archived rows violate {constraint['conname']}" + + +async def _validate_material_custody( + connection: asyncpg.Connection, seed: dict[str, object] +) -> None: + """Bind the archived request to the exact Submission and native ART rows.""" + request = seed["request"] + created = seed["created"] + request_body = {key: value for key, value in request.items() if key != "request_sha256"} + assert canonical_json_hash(request_body) == request["request_sha256"] + row = await connection.fetchrow(""" + SELECT s.id::text AS submission_id,s.version,s.task_id::text,s.task_assignment_id::text, + s.submission_bundle_admission_id::text AS admission_id, + s.artifact_binding_id::text AS binding_id,s.artifact_content_id::text AS content_id, + a.status,a.consumed_by_submission_id::text AS consumed_submission_id, + a.consumed_by_submission_version,b.resource_type,b.resource_id::text, + b.content_id::text AS binding_content_id,c.sha256, + r.content_id::text AS replica_content_id,t.status AS task_status + FROM public.submissions s + JOIN public.submission_bundle_admissions a ON a.id=s.submission_bundle_admission_id + JOIN public.artifact_bindings b ON b.id=s.artifact_binding_id + JOIN public.artifact_contents c ON c.id=s.artifact_content_id + JOIN public.artifact_replicas r ON r.id=$2::uuid + JOIN public.workstream_tasks t ON t.id=s.task_id + WHERE s.id=$1::uuid + """, created["submission_id"], seed["replica_id"]) + assert row is not None + assert row["submission_id"] == request["submission_id"] == created["submission_id"] + assert row["version"] == request["submission_version"] == created["submission_version"] + assert row["task_id"] == request["task_id"] + assert row["task_assignment_id"] == request["assignment_id"] + assert row["admission_id"] == created["admission_id"] + assert row["binding_id"] == request["binding_id"] == created["artifact_binding_id"] + assert row["content_id"] == request["content_id"] == created["artifact_content_id"] + assert row["status"] == "consumed" + assert row["consumed_submission_id"] == created["submission_id"] + assert row["consumed_by_submission_version"] == created["submission_version"] + assert (row["resource_type"], row["resource_id"]) == ( + "submission", created["submission_id"] ) + assert row["binding_content_id"] == row["replica_content_id"] == request["content_id"] + assert row["sha256"] == request["content_sha256"] + assert row["task_status"] == "submitted" + + +async def restore_archived_snapshot( + database_url: str, snapshot_name: str | None = None +) -> dict[str, object]: + """Restore literal rows into their exact isolated predecessor revision.""" + connection = await asyncpg.connect(_asyncpg_url(database_url)) + try: + await _assert_owned_test_database(connection, database_url) + await _assert_database_guards_enabled(connection) + revision = await connection.fetchval("SELECT version_num FROM public.alembic_version") + key = snapshot_name or revision + snapshot = deepcopy(_snapshots()[key]) + if snapshot_name is None: + assert key == revision + else: + assert snapshot["revision"] == revision + tables = tuple(sorted(snapshot["tables"])) + database_tables = tuple(row["tablename"] for row in await connection.fetch(""" + SELECT tablename FROM pg_tables + WHERE schemaname='public' ORDER BY tablename + """)) + assert set(tables).issubset(database_tables), "archived table inventory differs" + table_columns: dict[str, tuple[str, ...]] = {} + for table in tables: + columns = tuple(await connection.fetchval(""" + SELECT array_agg(column_name ORDER BY ordinal_position) + FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 + """, table)) + table_columns[table] = columns + assert all(set(row) == set(columns) for row in snapshot["tables"][table]), ( + f"archived columns differ for {table}" + ) + original_triggers = await _trigger_state(connection) + original_foreign_keys = await _foreign_keys(connection) + original_fk_state = tuple( + (row["oid"], row["condeferrable"], row["condeferred"]) + for row in original_foreign_keys + ) + async with connection.transaction(): + # A faithful backup restore is setup, not a current writer proof. + # User triggers resume before any migration assertion receives data. + for table in database_tables: + await connection.execute( + f"ALTER TABLE public.{_identifier(table)} DISABLE TRIGGER USER" + ) + changed_constraints: list[tuple[str, str]] = [] + for constraint in original_foreign_keys: + if not constraint["condeferrable"]: + table = _identifier(constraint["child_table"]) + name = _identifier(constraint["conname"]) + await connection.execute( + f"ALTER TABLE public.{table} ALTER CONSTRAINT {name} " + "DEFERRABLE INITIALLY DEFERRED" + ) + changed_constraints.append( + (constraint["child_table"], constraint["conname"]) + ) + await connection.execute("SET CONSTRAINTS ALL DEFERRED") + for table in database_tables: + await connection.execute(f"DELETE FROM public.{_identifier(table)}") + for table in tables: + await connection.execute( + f"INSERT INTO public.{_identifier(table)} " + f"SELECT * FROM jsonb_populate_recordset(" + f"NULL::public.{_identifier(table)},$1::jsonb)", + json.dumps( + snapshot["tables"][table], + sort_keys=True, + separators=(",", ":"), + ), + ) + await connection.execute("SET CONSTRAINTS ALL IMMEDIATE") + await _validate_foreign_keys(connection, await _foreign_keys(connection)) + for table in database_tables: + await connection.execute( + f"ALTER TABLE public.{_identifier(table)} ENABLE TRIGGER USER" + ) + for table_name, constraint_name in changed_constraints: + await connection.execute( + f"ALTER TABLE public.{_identifier(table_name)} " + f"ALTER CONSTRAINT {_identifier(constraint_name)} NOT DEFERRABLE" + ) + assert await _trigger_state(connection) == original_triggers + await _assert_database_guards_enabled(connection) + final_foreign_keys = await _foreign_keys(connection) + assert tuple( + (row["oid"], row["condeferrable"], row["condeferred"]) + for row in final_foreign_keys + ) == original_fk_state + nonempty_tables: list[str] = [] + for table in database_tables: + if await connection.fetchval( + f"SELECT EXISTS(SELECT 1 FROM public.{_identifier(table)})" + ): + nonempty_tables.append(table) + nonempty = tuple(nonempty_tables) + assert nonempty == tables, "archived nonempty table inventory differs" + for table in tables: + actual = sorted( + (json.loads(row["value"]) for row in await connection.fetch( + f"SELECT to_jsonb(r)::text AS value " + f"FROM public.{_identifier(table)} r" + )), + key=lambda row: json.dumps( + row, sort_keys=True, separators=(",", ":") + ), + ) + expected = sorted( + snapshot["tables"][table], + key=lambda row: json.dumps(row, sort_keys=True, separators=(",", ":")), + ) + assert actual == expected, f"archived rows differ for {table}" + if "archived_material" in snapshot["seed"]: + await _validate_material_custody(connection, snapshot["seed"]) + return deepcopy(snapshot["seed"]) + finally: + await connection.close() @asynccontextmanager -async def historical_material_fixture(tmp_path, database_url, **options): - async with AsyncExitStack() as stack: - async with current_art_attempt_seed_schema(database_url): - material = await stack.enter_async_context(_material_fixture( - tmp_path, database_url, write_submission=write_historical_submission, - read_request=_historical_request, **options, - )) - yield material +async def historical_material_fixture( + _tmp_path, database_url, *, snapshot_name: str | None = None +): + """Yield plain archived facts after the isolated predecessor restore closes.""" + seed = await restore_archived_snapshot(database_url, snapshot_name) + yield SimpleNamespace( + factory=db_session.get_session_factory(), + request=seed["request"], + created=seed["created"], + replica_id=seed["replica_id"], + archived_members=tuple(seed["archived_members"]), + archived_counts=seed["archived_counts"], + archived_material=seed["archived_material"], + ) diff --git a/backend/tests/migration_ancestor_rows.py b/backend/tests/migration_ancestor_rows.py new file mode 100644 index 000000000..768c21ccf --- /dev/null +++ b/backend/tests/migration_ancestor_rows.py @@ -0,0 +1,176 @@ +"""Literal Review ancestors for predecessor-storage migration proofs only. + +This seeder calls no current review, routing, checker or authorization service. +Installed SQL guards validate every archived relationship and digest. +""" + +from contextlib import asynccontextmanager +from datetime import timedelta +import json +from uuid import UUID + +from sqlalchemy import insert, text + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.reviews.models import ReviewQueueEntry, ReviewLease +from app.modules.reviews.packet.models import ReviewPacketManifest, ReviewPacketGuideItem +from app.modules.reviews.decision.models import Review, ReviewFinding, ReviewDecisionRequest +from tests.checkers.execution.historical_execution import archived_completed_checker_source + + +def _uuid_json(value): + if isinstance(value, UUID): + return str(value) + raise TypeError(f"Non-JSON archived fact: {type(value).__name__}") + + +def _json(value): + return json.loads(json.dumps(value, default=_uuid_json, allow_nan=False)) + + +@asynccontextmanager +async def archived_packet_ancestors(tmp_path, database_url, **options): + async with archived_completed_checker_source(tmp_path, database_url, **options) as h: + await seed_packet_ancestors(h) + yield h + + +async def seed_packet_ancestors(h): + async with h.factory() as session, session.begin(): + revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) + assert 11 <= int(revision[:4]) <= 28 + submission = (await session.execute(text( + "SELECT s.*,t.project_id FROM public.submissions s " + "JOIN public.workstream_tasks t ON t.id=s.task_id WHERE s.id=:id" + ), {"id": UUID(h.request["submission_id"])})).mappings().one() + guide = (await session.execute(text( + "SELECT * FROM public.project_guides WHERE project_id=:project AND version=:version" + ), {"project": submission["project_id"], "version": submission["locked_guide_version"]})).mappings().one() + activation = await session.scalar(text( + "SELECT response_json FROM public.guide_mutation_idempotency_records WHERE operation_id=:id" + ), {"id": guide["activation_operation_id"]}) + proposal = activation["command"]["target"]["proposal"] + documents = (await session.execute(text("""SELECT i.id AS ingest_id, + s.id AS source_item_id, s.item_order, s.media_type + FROM public.guide_source_snapshot_items s + JOIN public.guide_source_artifact_ingests i ON i.source_item_id=s.id + WHERE s.source_snapshot_id=:id ORDER BY s.item_order"""), + {"id": submission["locked_guide_source_snapshot_id"]})).mappings().all() + reviewer = await session.scalar(text("""SELECT id FROM public.actor_profiles + WHERE service_identity IS NULL AND id<>:contributor AND status='active' + ORDER BY id LIMIT 1"""), {"contributor": submission["contributor_id"]}) + assert reviewer is not None, "archived base must include a distinct human reviewer" + now = await session.scalar(text("SELECT clock_timestamp()")) + h.queue_id, h.lease_id = new_record_id(), new_record_id() + await session.execute(insert(ReviewQueueEntry).values( + id=h.queue_id, project_id=str(submission["project_id"]), task_id=str(submission["task_id"]), + submission_id=str(submission["id"]), submission_version=submission["version"], + admitting_checker_run_id=h.result["attempt_id"], routing_mode="open", + routing_reason="first_submission", queue_state="pending", active_lease_id=None, + routing_generation=1, lifecycle_generation=1, first_queued_at=now, available_since=now, + )) + # The pending-insert deferred guard must see the committed queue without a + # lease before the separate claim transaction creates its active lease. + async with h.factory() as session, session.begin(): + await session.execute(insert(ReviewLease).values( + id=h.lease_id, review_queue_entry_id=h.queue_id, + project_id=str(submission["project_id"]), task_id=str(submission["task_id"]), + submission_id=str(submission["id"]), submission_version=submission["version"], + reviewer_id=str(reviewer), reviewer_contribution_policy_version_id=h.source["contribution_policy_version_id"], + attempt_generation=1, expires_at=now+timedelta(days=1), status="active", claimed_at=now, + )) + await session.execute(text("""UPDATE public.review_queue_entries + SET queue_state='leased',active_lease_id=:lease WHERE id=:id"""), + {"id": h.queue_id, "lease": h.lease_id}) + request = { + "project_id": str(submission["project_id"]), "task_id": str(submission["task_id"]), + "submission_id": str(submission["id"]), "submission_version": submission["version"], + "checker_run_id": h.result["attempt_id"], "result_id": h.result["result_id"], + "guide_id": str(guide["id"]), "guide_version": guide["version"], + "source_snapshot_id": str(submission["locked_guide_source_snapshot_id"]), + "project_setup_run_id": proposal["setup_run_id"], "setup_generation": proposal["setup_generation"], + } + h.packet_body = {"request": request, "submission": { + "binding_id": str(submission["artifact_binding_id"]), + "logical_role": "submission_bundle_original", "media_type": "application/zip", + }, "guide_documents": [{ + "ingest_id": str(d["ingest_id"]), "source_item_id": str(d["source_item_id"]), + "item_order": d["item_order"], "logical_role": "guide_source_original", "media_type": d["media_type"], + } for d in documents]} + h.archived_submission = dict(submission) + h.archived_reviewer_id = reviewer + + +@asynccontextmanager +async def archived_review_ancestors(tmp_path, database_url, *, decision="accept", **options): + async with archived_packet_ancestors(tmp_path, database_url, **options) as h: + await seed_review_ancestors(h, decision=decision) + yield h + + +async def seed_review_ancestors(h, *, decision): + async with h.factory() as session, session.begin(): + revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) + assert 12 <= int(revision[:4]) <= 28 + h.packet_id = new_record_id() + digest = canonical_json_hash(h.packet_body) + request = h.packet_body["request"] + await session.execute(insert(ReviewPacketManifest).values( + id=h.packet_id, review_lease_id=h.lease_id, review_queue_entry_id=h.queue_id, + packet_manifest_generation=1, packet_manifest_digest=digest, + **request, submission_binding_id=h.packet_body["submission"]["binding_id"], + submission_logical_role="submission_bundle_original", submission_media_type="application/zip", + )) + for member in h.packet_body["guide_documents"]: + await session.execute(insert(ReviewPacketGuideItem).values( + packet_id=h.packet_id, **member, + )) + s = h.archived_submission + artifact_hash = await session.scalar(text( + "SELECT sha256 FROM public.artifact_contents WHERE id=:id" + ), {"id": s["artifact_content_id"]}) + h.review = { + "id": new_record_id(), "project_id": s["project_id"], "task_id": s["task_id"], + "task_assignment_id": s["task_assignment_id"], "submission_id": s["id"], + "submission_version": s["version"], "review_queue_entry_id": h.queue_id, + "review_lease_id": h.lease_id, "packet_manifest_id": h.packet_id, + "packet_manifest_digest": digest, "artifact_hash": artifact_hash, + "reviewer_id": h.archived_reviewer_id, + "reviewer_contribution_policy_version_id": h.source["contribution_policy_version_id"], + "locked_guide_version": s["locked_guide_version"], + "locked_review_policy_id": s["locked_review_policy_id"], + "locked_review_policy_generation": s["locked_review_policy_generation"], + "locked_review_policy_hash": s["locked_review_policy_hash"], "predecessor_review_id": None, + "decision": decision, "summary": "Verified the exact submitted work.", + "findings": ([{"id": new_record_id(), "finding_kind": "blocking", "area": "Deliverable", + "issue": "Required result", "required_fix": "Correct the documented result."}] + if decision == "needs_revision" else []), "resolutions": [], + } + + +async def seed_review_row(session, review): + source = _json(review) + findings = source["findings"] + counts = {"finding_count": len(findings), + "blocking_finding_count": sum(f["finding_kind"] == "blocking" for f in findings), + "resolution_count": 0} + digest = canonical_json_hash(source | counts | {"domain": "workstream.review_source.v0.1"}) + values = {k: v for k, v in review.items() if k not in {"findings", "resolutions"}} + await session.execute(insert(Review).values(**values, **counts, aggregate_digest=digest)) + for order, member in enumerate(review["findings"]): + await session.execute(insert(ReviewFinding).values(**member, review_id=review["id"], item_order=order)) + request_body = source | counts | {"action": "review.decision", "domain": "workstream.review_decision_request.v0.1"} + request_body.pop("id") + for member in request_body["findings"]: + member.pop("id") + await session.execute(insert(ReviewDecisionRequest).values( + id=new_record_id(), operation_id=new_record_id(), project_id=review["project_id"], + reviewer_id=review["reviewer_id"], idempotency_key=new_record_id(), review_id=review["id"], + request_digest=canonical_json_hash(request_body), + )) + await session.execute(text("""UPDATE public.review_leases SET status='consumed', + close_reason='review_recorded',closed_at=clock_timestamp() WHERE id=:id"""), {"id": review["review_lease_id"]}) + await session.execute(text("""UPDATE public.review_queue_entries SET queue_state='closed', + closed_reason='review_recorded',active_lease_id=NULL,closed_at=clock_timestamp() WHERE id=:id"""), + {"id": review["review_queue_entry_id"]}) diff --git a/backend/tests/migration_fixtures.py b/backend/tests/migration_fixtures.py index edcdaf8ab..7734b8d19 100644 --- a/backend/tests/migration_fixtures.py +++ b/backend/tests/migration_fixtures.py @@ -1,7 +1,6 @@ """Canonical current-head lookup for current-schema assertions.""" from pathlib import Path -from contextlib import asynccontextmanager from alembic.config import Config from alembic.script import ScriptDirectory @@ -16,136 +15,3 @@ def current_schema_revision(): heads = ScriptDirectory.from_config(_config()).get_heads() assert len(heads) == 1 return heads[0] - - -_CAUSE_SEED_TABLES = ( - ("pre_submit_evidence_sets", "id"), - ("submission_bundle_admissions", "id"), - ("submission_dispatches", "submission_id"), -) - - -async def _add_current_cause_seed_columns(connection): - """Let the current initial writer seed older tables without inventing a cause.""" - originals = {} - for table, _ in _CAUSE_SEED_TABLES: - columns = await connection.fetch( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name=$1 ORDER BY ordinal_position", table, - ) - names = tuple(row[0] for row in columns) - if names and "checker_remediation_manifest_id" not in names: - revision = await connection.fetchval("SELECT version_num FROM public.alembic_version") - assert int(revision[:4]) < 33, "only a predecessor may need seeding columns" - await connection.execute( - f"ALTER TABLE public.{table} ADD COLUMN checker_remediation_manifest_id uuid" - ) - originals[table] = names - return originals - - -async def _restore_current_cause_seed_columns(connection, originals): - for table, names in originals.items(): - key = next(order for name, order in _CAUSE_SEED_TABLES if name == table) - assert await connection.fetchval( - f"SELECT count(*) FROM public.{table} " - "WHERE checker_remediation_manifest_id IS NOT NULL" - ) == 0 - before = await connection.fetch( - f"SELECT (to_jsonb(r) - 'checker_remediation_manifest_id')::text AS value " - f"FROM public.{table} r ORDER BY {key}" - ) - await connection.execute( - f"ALTER TABLE public.{table} DROP COLUMN checker_remediation_manifest_id" - ) - columns = await connection.fetch( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name=$1 ORDER BY ordinal_position", table, - ) - assert tuple(row[0] for row in columns) == names - assert await connection.fetch( - f"SELECT to_jsonb(r)::text AS value FROM public.{table} r ORDER BY {key}" - ) == before - - -@asynccontextmanager -async def current_art_attempt_seed_schema(database_url): - """Restore a predecessor's attempt shape before the migration under test.""" - import asyncpg - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - owned = False - cause_originals = {} - try: - columns = await connection.fetch("SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name='artifact_put_attempts' ORDER BY ordinal_position") - names = tuple(row[0] for row in columns) - if "task_import_source_id" not in names: - revision = await connection.fetchval("SELECT version_num FROM public.alembic_version") - assert int(revision[:4]) < 31 and names, "only a populated predecessor may need seeding columns" - await connection.execute("ALTER TABLE public.artifact_put_attempts ADD COLUMN task_import_source_id uuid") - owned = True - cause_originals = await _add_current_cause_seed_columns(connection) - yield - finally: - try: - await _restore_current_cause_seed_columns(connection, cause_originals) - if owned: - assert await connection.fetchval("SELECT count(*) FROM public.artifact_put_attempts " - "WHERE task_import_source_id IS NOT NULL") == 0 - before = await connection.fetch("SELECT (to_jsonb(r) - 'task_import_source_id')::text AS value " - "FROM public.artifact_put_attempts r ORDER BY id") - await connection.execute("ALTER TABLE public.artifact_put_attempts DROP COLUMN task_import_source_id") - restored = await connection.fetch("SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name='artifact_put_attempts' ORDER BY ordinal_position") - assert tuple(row[0] for row in restored) == names - assert await connection.fetch("SELECT to_jsonb(r)::text AS value FROM public.artifact_put_attempts r ORDER BY id") == before - finally: - await connection.close() - - -async def add_current_art_seed_column(database_url): - """Temporarily admit current ART columns; restore the predecessor before proof.""" - import asyncpg - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - try: - columns = await connection.fetch("SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name='pre_submit_evidence_sets' ORDER BY ordinal_position") - names = tuple(row[0] for row in columns) - assert "semantic_manifest_body" not in names - attempt_columns = await connection.fetch("SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name='artifact_put_attempts' ORDER BY ordinal_position") - attempt_names = tuple(row[0] for row in attempt_columns) - assert "task_import_source_id" not in attempt_names - await connection.execute("ALTER TABLE public.pre_submit_evidence_sets ADD COLUMN semantic_manifest_body jsonb") - await connection.execute("ALTER TABLE public.artifact_put_attempts ADD COLUMN task_import_source_id uuid") - cause_originals = await _add_current_cause_seed_columns(connection) - return names, attempt_names, cause_originals - finally: - await connection.close() - - -async def restore_predecessor_evidence_schema(database_url, original_columns): - """Restore predecessor row shape before snapshots, upgrades, and concurrency probes.""" - import asyncpg - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - try: - await _restore_current_cause_seed_columns(connection, original_columns[2]) - evidence_before = await connection.fetch("SELECT (to_jsonb(r) - 'semantic_manifest_body')::text AS value " - "FROM public.pre_submit_evidence_sets r ORDER BY id") - assert evidence_before, "historical fixture must retain actual evidence" - attempts_before = await connection.fetch("SELECT (to_jsonb(r) - 'task_import_source_id')::text AS value " - "FROM public.artifact_put_attempts r ORDER BY id") - assert await connection.fetchval("SELECT count(*) FROM public.artifact_put_attempts " - "WHERE task_import_source_id IS NOT NULL") == 0 - await connection.execute("ALTER TABLE public.pre_submit_evidence_sets DROP COLUMN semantic_manifest_body") - await connection.execute("ALTER TABLE public.artifact_put_attempts DROP COLUMN task_import_source_id") - columns = await connection.fetch("SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name='pre_submit_evidence_sets' ORDER BY ordinal_position") - assert tuple(row[0] for row in columns) == original_columns[0] - attempt_columns = await connection.fetch("SELECT column_name FROM information_schema.columns " - "WHERE table_schema='public' AND table_name='artifact_put_attempts' ORDER BY ordinal_position") - assert tuple(row[0] for row in attempt_columns) == original_columns[1] - assert await connection.fetch("SELECT to_jsonb(r)::text AS value FROM public.pre_submit_evidence_sets r ORDER BY id") == evidence_before - assert await connection.fetch("SELECT to_jsonb(r)::text AS value FROM public.artifact_put_attempts r ORDER BY id") == attempts_before - finally: - await connection.close() diff --git a/backend/tests/outbox/test_delivery_topology.py b/backend/tests/outbox/test_delivery_topology.py index 13687d9a0..822c67667 100644 --- a/backend/tests/outbox/test_delivery_topology.py +++ b/backend/tests/outbox/test_delivery_topology.py @@ -68,7 +68,8 @@ def test_validated_prefork_child_enters(monkeypatch): topology.validate_outbox_worker(configured) monkeypatch.setattr(topology.os, "getppid", os.getpid) topology.require_outbox_worker(SimpleNamespace(app=configured.app, - time_limit=300, request=SimpleNamespace(is_eager=False, called_directly=False, timelimit=None))) + time_limit=topology.OUTBOX_HARD_LIMIT_SECONDS, + request=SimpleNamespace(is_eager=False, called_directly=False, timelimit=None))) def test_synchronous_task_rejects_before_sql(monkeypatch): @@ -104,7 +105,12 @@ def test_delivery_routes_to_dedicated_queue(monkeypatch): get_settings.cache_clear() -@pytest.mark.parametrize("task_limit,message_limit", [(None, None), (301, None), (300, 301)]) +@pytest.mark.parametrize("task_limit,message_limit", [ + (None, None), (300, None), (topology.OUTBOX_HARD_LIMIT_SECONDS + 1, None), + (topology.OUTBOX_HARD_LIMIT_SECONDS, 300), + (topology.OUTBOX_HARD_LIMIT_SECONDS, topology.OUTBOX_HARD_LIMIT_SECONDS + 1), + (topology.OUTBOX_HARD_LIMIT_SECONDS, 0), +]) def test_delivery_cannot_disable_or_extend_process_bound(monkeypatch, task_limit, message_limit): configured = worker() topology.validate_outbox_worker(configured) diff --git a/backend/tests/outbox/test_worker_postgresql.py b/backend/tests/outbox/test_worker_postgresql.py index e8a51c00c..0b338ab68 100644 --- a/backend/tests/outbox/test_worker_postgresql.py +++ b/backend/tests/outbox/test_worker_postgresql.py @@ -187,13 +187,18 @@ def publish(*, args): assert attempts == [(str(cursor), str(selectors[0].project_id)), (str(cursor),)] -async def test_production_registry_claims_only_registered_invalidation(delivery_harness): +async def test_production_registry_claims_only_registered_features(delivery_harness): from app.adapters.outbox import production_outbox_delivery + from app.modules.checkers.api.execution import COMPLETION_EVENT, REQUEST_EVENT h = delivery_harness event = await h.append() production = production_outbox_delivery(h.factory) - assert production._registry.keys == (("TaskAssignmentAuthorityInvalidationRequested", 1),) + assert production._registry.keys == ( + ("TaskAssignmentAuthorityInvalidationRequested", 1), + (REQUEST_EVENT, 1), + (COMPLETION_EVENT, 1), + ) assert (await production.candidates()).items == () assert await production.deliver(event.event_id, h.project, "worker") is None async with h.factory() as session: @@ -239,6 +244,8 @@ async def stubborn(envelope): # prefork task hanging until the independent test watchdog detects it. if task.time_limit is not None: monkeypatch.setattr(task, "time_limit", 5) + from app.workers import outbox_topology + monkeypatch.setattr(outbox_topology, "OUTBOX_HARD_LIMIT_SECONDS", 5) monkeypatch.setenv("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") get_settings.cache_clear() acked = Event() @@ -253,9 +260,14 @@ async def stubborn(envelope): properties={"correlation_id": task_id}, body=((str(event.event_id), str(h.project)), {}, {}), ) - request = Request(message, app=worker.celery_app, task=task, decoded=True, - on_ack=lambda *args: acked.set(), - on_reject=lambda *args: rejected.append(args)) + request = Request( + message, + app=worker.celery_app, + task=task, + decoded=True, + on_ack=lambda *args: acked.set(), + on_reject=lambda *args: rejected.append(args), + ) result = request.execute_using_pool(pool) with pytest.raises(ExceptionWithTraceback) as timed_out: result.get(timeout=15) @@ -264,13 +276,21 @@ async def stubborn(envelope): assert rejected == [] # The pool owns waitpid/reaping. A competing join can observe stale # returncode metadata; the owned child sentinel proves actual exit. - assert wait([child.sentinel], timeout=5), "hard timeout must terminate the stuck worker process" + assert wait([child.sentinel], timeout=5), ( + "hard timeout must terminate the stuck worker process" + ) duplicate_id = str(new_record_id()) - duplicate = Request(Message( - headers={"id": duplicate_id, "task": task.name}, - properties={"correlation_id": duplicate_id}, body=message.body, - ), app=worker.celery_app, task=task, decoded=True, - on_reject=lambda *args: rejected.append(args)) + duplicate = Request( + Message( + headers={"id": duplicate_id, "task": task.name}, + properties={"correlation_id": duplicate_id}, + body=message.body, + ), + app=worker.celery_app, + task=task, + decoded=True, + on_reject=lambda *args: rejected.append(args), + ) failed, body, _ = duplicate.execute_using_pool(pool).get(timeout=15) assert not failed and ast.literal_eval(body) == {"status": "delivery_unavailable"} assert duplicate.worker_pid != child.pid @@ -301,7 +321,14 @@ def test_delivery_task_has_independent_hard_limit(worker): from app.modules.outbox.api import DeliveryOptions task = worker.deliver_event - assert task.time_limit == 300 + from app.modules.checkers.api.external_policy import ( + SELECTED_POST_DELIVERY_LEASE_SECONDS, SELECTED_POST_HANDLER_TIMEOUT_SECONDS, + ) + from app.workers.outbox_topology import OUTBOX_PROCESS_SHUTDOWN_MARGIN_SECONDS + assert task.time_limit == ( + SELECTED_POST_DELIVERY_LEASE_SECONDS + OUTBOX_PROCESS_SHUTDOWN_MARGIN_SECONDS + ) + assert task.time_limit > SELECTED_POST_HANDLER_TIMEOUT_SECONDS assert task.time_limit > DeliveryOptions().handler_timeout_seconds assert task.acks_late and task.acks_on_failure_or_timeout assert task.reject_on_worker_lost is True diff --git a/backend/tests/post_submit_materialization_helpers.py b/backend/tests/post_submit_materialization_helpers.py index 5be6476d0..0e996df14 100644 --- a/backend/tests/post_submit_materialization_helpers.py +++ b/backend/tests/post_submit_materialization_helpers.py @@ -17,19 +17,22 @@ from app.modules.artifacts.models import SubmissionBundleAdmission from app.modules.artifacts.preparation import ArtifactPreparationService, ArtifactScratchManager from app.modules.artifacts.service import artifact_storage_namespace_spec -from app.modules.artifacts.submission_archive import SubmissionArchiveInspector, SubmissionArchiveLimits +from app.modules.artifacts.submission_archive import ( + SubmissionArchiveInspector, + SubmissionArchiveLimits, +) from app.modules.artifacts.submission_manifest import build_submission_manifest from app.modules.authorization.api import ActorIdentityFacts, ActorKind from app.modules.tasks.api import SubmissionCreationRequest from app.modules.tasks.api.submitted_bundle import SubmittedBundleRequest from tests.artifact_store_helpers import artifact_admission_limit_settings -from tests.pre_submit_test_helpers import approved_pre_submit_fixture +from tests.selected_policy_fixtures import approved_selected_policy_fixture from tests.submission_preparation_auth_helpers import install_submitter_grant from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test from tests.tasks.submission_lineage_support import _seed_services, _verified_admission from tests.test_artifact_admission import _settings, _context, _seed_human_actor from tests.test_checker_materialization import _limits -from tests.test_default_pre_submit_execution import _archive, _bytes +from tests.archive_fixtures import byte_source as _bytes, submission_archive as _archive class CountedStore: @@ -61,6 +64,7 @@ def archive_with_modes(evidence_path, project_id): async def provision_material_services(factory, *, artifacts, checker): """Provision only the fixed identities present in the schema under test.""" from tests.checkers.execution.support import provision_post_submit_service + if artifacts: await _seed_services(factory) if checker: @@ -74,6 +78,7 @@ def _archive_facts(data): with zipfile.ZipFile(BytesIO(data)) as archive: files = {name: archive.read(name) for name in archive.namelist() if not name.endswith("/")} import hashlib + digest = "sha256:" + hashlib.sha256(data).hexdigest() return inspector, manifest, files, digest @@ -81,14 +86,20 @@ def _archive_facts(data): async def _write_current_submission(factory, context, request): async with factory() as session: return await compose_hidden_submission_creation_command( - session, context, request_id=new_record_id(), correlation_id=new_record_id(), + session, + context, + request_id=new_record_id(), + correlation_id=new_record_id(), ).create(request) async def _read_current_request(session, facts, created, data): from app.adapters.checkers import evaluation_coordinator + stored = await evaluation_coordinator(session).read_reserved_evaluation( - project_id=facts.project_id, task_id=facts.task_id, submission_id=created.submission_id, + project_id=facts.project_id, + task_id=facts.task_id, + submission_id=created.submission_id, request_id=created.evaluation_request_id, ) return stored.request @@ -98,12 +109,114 @@ async def _read_current_request(session, facts, created, data): async def material_fixture(tmp_path, database_url, **options): """Current canonical creation always commits its own exact initial request.""" async with _material_fixture( - tmp_path, database_url, write_submission=_write_current_submission, - read_request=_read_current_request, **options, + tmp_path, + database_url, + write_submission=_write_current_submission, + read_request=_read_current_request, + **options, ) as material: yield material +def _material_storage(tmp_path, provider, storage_settings, scratch_limits, post_scratch_root): + if storage_settings is not None: + settings = storage_settings + elif provider == "minio": + from tests.test_s3_artifact_store import minio_settings + + settings = minio_settings(private_prefix=f"material/{new_record_id()}").model_copy( + update={ + **artifact_admission_limit_settings(1024 * 1024), + "artifact_scratch_root": tmp_path / "intake-scratch", + "artifact_scratch_minimum_free_bytes": 0, + } + ) + else: + settings = _settings(tmp_path, maximum_bytes=1024 * 1024).model_copy( + update={ + "external_checker_service_socket": tmp_path / "checker.sock", + "external_checker_material_root": tmp_path / "scratch", + } + ) + bootstrap = create_artifact_store_bootstrap(settings) + namespace = artifact_storage_namespace_spec(settings, bootstrap) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + bootstrap.identity, + bootstrap.namespace_identity, + namespace.namespace_fingerprint, + ) + ) + scratch = post_scratch_root or tmp_path / "post-scratch" + manager = ArtifactScratchManager( + root=scratch, + limits=scratch_limits if scratch_limits is not None else _limits(), + ) + return settings, bootstrap, namespace, store, scratch, manager + + +async def _create_material_submission( + factory, + engine, + plan, + policy, + store, + namespace, + settings, + write_submission, + *, + provision_services, + provision_checker, +): + context = _context() + await provision_material_services( + factory, artifacts=provision_services, checker=provision_checker + ) + task_id, assignment_id = new_record_id(), new_record_id() + async with factory.begin() as session: + await _seed_human_actor(session, context) + params = dict( + task=str(task_id), + assignment=str(assignment_id), + project=str(plan.lineage.project_id), + actor=str(context.actor_profile_id), + ) + async with engine.begin() as connection: + await seed_started_task_for_artifact_test(connection, params) + await install_submitter_grant(connection, params) + data = archive_with_modes(policy["evidence_path"], plan.lineage.project_id) + preparation_request = SubmissionBundlePreparationRequest( + actor=ActorIdentityFacts( + context.actor_profile_id, context.identity_link_id, ActorKind.HUMAN + ), + request_id=context.request_id, + correlation_id=context.correlation_id, + task_id=task_id, + assignment_id=assignment_id, + predecessor_submission_id=None, + idempotency_key=new_record_id(), + summary="Completed the required project work and included evidence.", + contributor_attestation="I confirm no confidential client data, credentials, or copied source material is included in this submission; rights_confirmed. " + + " ".join(policy["attestation_terms"]), + media_type="application/zip", + byte_source=_bytes(data), + ) + admission_id = await _verified_admission( + factory, store, namespace, settings, context, preparation_request + ) + creation_request = SubmissionCreationRequest( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=context.actor_profile_id, + predecessor_submission_id=None, + admission_id=admission_id, + summary=preparation_request.summary, + contributor_attestation=preparation_request.contributor_attestation, + ) + created = await write_submission(factory, context, creation_request) + return context, task_id, data, admission_id, creation_request, created + + @asynccontextmanager async def _material_fixture( tmp_path, @@ -118,65 +231,52 @@ async def _material_fixture( provision_checker=True, contribution_awards=(), human_review_required=True, + pre_submit_registry_spec=None, + post_submit_registry_spec=None, + post_scratch_root=None, ): engine = create_async_engine(database_url) factory = async_sessionmaker(engine, expire_on_commit=False) - if storage_settings is not None: - settings = storage_settings - elif provider == "minio": - from tests.test_s3_artifact_store import minio_settings - settings = minio_settings(private_prefix=f"material/{new_record_id()}").model_copy(update={ - **artifact_admission_limit_settings(1024 * 1024), - "artifact_scratch_root": tmp_path / "intake-scratch", - "artifact_scratch_minimum_free_bytes": 0, - }) - else: - settings = _settings(tmp_path, maximum_bytes=1024 * 1024) - bootstrap = create_artifact_store_bootstrap(settings) - 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 / "post-scratch", limits=scratch_limits if scratch_limits is not None else _limits()) + settings, bootstrap, namespace, store, scratch, manager = _material_storage( + tmp_path, provider, storage_settings, scratch_limits, post_scratch_root + ) try: - plan, policy = await approved_pre_submit_fixture( + plan, policy = await approved_selected_policy_fixture( factory, namespace, guide_version="v1", contribution_awards=contribution_awards, human_review_required=human_review_required, + pre_submit_registry_spec=pre_submit_registry_spec, + post_submit_registry_spec=post_submit_registry_spec, ) - context = _context() - await provision_material_services(factory, artifacts=provision_services, checker=provision_checker) - task_id, assignment_id = new_record_id(), new_record_id() - async with factory.begin() as session: - await _seed_human_actor(session, context) - async with engine.begin() as connection: - params = dict(task=str(task_id), assignment=str(assignment_id), - project=str(plan.lineage.project_id), actor=str(context.actor_profile_id)) - await seed_started_task_for_artifact_test(connection, params) - await install_submitter_grant(connection, params) - data = archive_with_modes(policy["evidence_path"], plan.lineage.project_id) - preparation_request = SubmissionBundlePreparationRequest( - actor=ActorIdentityFacts(context.actor_profile_id, context.identity_link_id, ActorKind.HUMAN), - request_id=context.request_id, correlation_id=context.correlation_id, - task_id=task_id, assignment_id=assignment_id, predecessor_submission_id=None, - idempotency_key=new_record_id(), summary="Completed the required project work and included evidence.", - contributor_attestation="I confirm no confidential client data, credentials, or copied source material is included in this submission; rights_confirmed. " + " ".join(policy["attestation_terms"]), - media_type="application/zip", byte_source=_bytes(data), - ) - admission_id = await _verified_admission(factory, store, namespace, settings, context, preparation_request) - creation_request = SubmissionCreationRequest( - task_id=task_id, assignment_id=assignment_id, contributor_id=context.actor_profile_id, - predecessor_submission_id=None, admission_id=admission_id, - summary=preparation_request.summary, - contributor_attestation=preparation_request.contributor_attestation, + ( + context, + task_id, + data, + admission_id, + creation_request, + created, + ) = await _create_material_submission( + factory, + engine, + plan, + policy, + store, + namespace, + settings, + write_submission, + provision_services=provision_services, + provision_checker=provision_checker, ) - created = await write_submission(factory, context, creation_request) async with factory() as session: - facts = await submitted_bundle_port(session).read(SubmittedBundleRequest( - plan.lineage.project_id, task_id, created.submission_id, - )) + facts = await submitted_bundle_port(session).read( + SubmittedBundleRequest( + plan.lineage.project_id, + task_id, + created.submission_id, + ) + ) admission = await session.get(SubmissionBundleAdmission, str(admission_id)) replica_id = UUID(admission.verified_replica_id) request = await read_request(session, facts, created, data) @@ -184,16 +284,33 @@ async def _material_fixture( counted = CountedStore(store) preparation = ArtifactPreparationService(manager) service = post_submission_materialization( - sessions=factory, store=counted, namespace=namespace, - preparation=preparation, inspector=inspector, + sessions=factory, + store=counted, + namespace=namespace, + preparation=preparation, + inspector=inspector, + ) + yield SimpleNamespace( + replica_id=replica_id, + service=service, + factory=factory, + engine=engine, + request=request, + created=created, + facts=facts, + files=files, + data=data, + manifest=manifest, + actor_context=context, + creation_request=creation_request, + settings=settings, + store=counted, + namespace=namespace, + preparation=preparation, + manager=manager, + inspector=inspector, + scratch=scratch, ) - yield SimpleNamespace(replica_id=replica_id, service=service, factory=factory, engine=engine, request=request, - created=created, facts=facts, files=files, data=data, manifest=manifest, - actor_context=context, creation_request=creation_request, - settings=settings, - store=counted, namespace=namespace, preparation=preparation, - manager=manager, inspector=inspector, - scratch=tmp_path / "post-scratch") finally: manager.close() bootstrap.close() diff --git a/backend/tests/pre_submit_test_helpers.py b/backend/tests/pre_submit_test_helpers.py deleted file mode 100644 index 06392aa29..000000000 --- a/backend/tests/pre_submit_test_helpers.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Shared dependency-safe construction for focused pre-submit tests.""" - -import json -from dataclasses import replace -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from sqlalchemy import select, text -from sqlalchemy.exc import DBAPIError -from uuid import UUID, uuid4 -from app.core.identifiers import new_record_id - -from app.adapters.checkers import PreSubmitCheckerExecutionAdapter -from app.adapters.tasks import task_submission_context_port -from app.modules.artifacts.api import SubmissionBundlePreparationRequest -from app.modules.artifacts.submission_materialization import ( - PreparedBundleMaterializationService, - PreparedBundlePreSubmitEvidenceService, -) -from app.modules.authorization.api import ActorIdentityFacts, ActorKind -from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository -from app.modules.artifacts.pre_submit_attempts import PreSubmitAttemptClaim -from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidencePersistenceResult - - -async def materialize_member_fixture(service, request): - """Isolate existing member/scratch tests from SQL; this is not attempt-custody proof.""" - claim = object.__new__(PreSubmitAttemptClaim) - claim.attempt_id, claim._nonce = new_record_id(), uuid4() - claim.request_digest = "sha256:" + "1" * 64 - claim._request = replace(request, prepared_authorization=None) - claim._started, claim._execution = False, None - claim._session = service._session - service._session.in_transaction = lambda: True - service._session.in_nested_transaction = lambda: False - service._session.scalar = AsyncMock(return_value=SimpleNamespace( - status="reserved", evidence_set_id=None, claim_nonce=str(claim._nonce), - request_digest=claim.request_digest, - prepared_generation_id=str(request.prepared_artifact.generation_id), - )) - return await service.materialize_prepared_bundle(request, claim=claim) - - -async def execute_evidence_workflow(workflow, request, *, preparation_request): - """Exercise real reservation/commit/execution/evidence owners in PostgreSQL fixtures.""" - async with workflow._session.begin(): - selected = await workflow.reserve(request, preparation_request=preparation_request) - if isinstance(selected, PreSubmitEvidencePersistenceResult): - return selected - return await workflow.execute_reserved(request, selected, preparation_request=preparation_request) - - -def checker_execution(inspector, catalogue) -> PreSubmitCheckerExecutionAdapter: - """Build the owner adapter used by ART materialization tests.""" - return PreSubmitCheckerExecutionAdapter( - archive_inspector=inspector, - catalogue=catalogue, - ) - - -def submission_preparation_request( - request, - *, - actor_profile_id: UUID, - identity_link_id: UUID, -) -> SubmissionBundlePreparationRequest: - """Project one private materialization fixture into the public ART request.""" - return SubmissionBundlePreparationRequest( - actor=ActorIdentityFacts( - actor_profile_id=actor_profile_id, - identity_link_id=identity_link_id, - actor_kind=ActorKind.HUMAN, - ), - request_id=uuid4(), - correlation_id=uuid4(), - task_id=request.task_id, - assignment_id=request.assignment_id, - predecessor_submission_id=None, - idempotency_key=uuid4(), - summary=request.packet.summary, - contributor_attestation=request.packet.contributor_attestation, - media_type="application/zip", - byte_source=_empty_bytes(), - ) - - -async def _empty_bytes(): - if False: # pragma: no cover - preserve the async-iterable request shape - yield b"" - - -def evidence_workflow( - *, session, preparation, inspector, catalogue, materialization_authorization, - preparation_authorization, -) -> PreparedBundlePreSubmitEvidenceService: - """Compose the exact public-port evidence workflow for database proof.""" - return PreparedBundlePreSubmitEvidenceService( - session=session, - materialization=PreparedBundleMaterializationService( - session=session, - authorization=materialization_authorization, - preparation=preparation, - checker_execution=checker_execution(inspector, catalogue), - storage_scheme="s3", - ), - preparation_authorization=preparation_authorization, - task_contexts=task_submission_context_port(session), - project_contexts=ProjectLockedPolicyRepository(session), - ) - - -async def approved_pre_submit_fixture( - factory, namespace, *, guide_version, contribution_awards=(), human_review_required=True -): - """Build supported PROJECTS custody before the ART evidence transaction.""" - from app.interfaces.project_agents import SubmissionArtifactPolicyProposal - from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue - from app.modules.checkers.api import EffectivePreSubmissionPlanLineage - from app.modules.projects.models import ProjectGuide, PostSubmitCheckerPolicy - from tests.projects.unified_policy_fixtures import create_standalone_unified_policy - - values, effective, pre = await create_standalone_unified_policy( - factory, - namespace, - guide_version=guide_version, - contribution_awards=contribution_awards, - human_review_required=human_review_required, - artifact_proposal=SubmissionArtifactPolicyProposal( - maximum_file_size_bytes=1_000_000, - maximum_package_size_bytes=5_000_000, - required_artifacts=("task.toml",), - required_evidence=("results",), - attestation_terms=("rights_confirmed",), - ), - ) - lineage = EffectivePreSubmissionPlanLineage( - project_id=values["project"], guide_id=values["guide"], guide_version=guide_version, - source_snapshot_id=values["snapshot"], source_snapshot_hash=effective["source_snapshot_hash"], - effective_policy_id=UUID(effective["id"]), effective_policy_hash=effective["effective_policy_hash"], - pre_submit_policy_id=UUID(pre.id), pre_submit_policy_bundle_hash=pre.compiled_bundle_hash, - ) - plan = build_pre_submission_checker_catalogue().compile_effective_plan( - lineage=lineage, effective_policy=effective["effective_policy"], compiled_bundle=pre.compiled_bundle, - ) - async with factory() as session: - guide = await session.get(ProjectGuide, str(values["guide"])) - post = await session.scalar(select(PostSubmitCheckerPolicy).where( - PostSubmitCheckerPolicy.effective_policy_id == effective["id"])) - assert post is not None - return plan, { - "post_policy": post.id, - "post_policy_hash": post.policy_hash, - "post_policy_body": json.dumps(post.policy_body), - "submission_policy": effective["submission_artifact_policy_id"], - "review_policy": guide.selected_review_policy_id, - "review_policy_hash": guide.selected_review_policy_hash, - "revision_policy": guide.selected_revision_policy_id, - "revision_policy_hash": guide.selected_revision_policy_hash, - "guide_version": guide_version, - "attestation_terms": effective["effective_policy"]["attestation_terms"], - "evidence_path": "evidence/" + effective["effective_policy"]["required_evidence"][0]["key"], - } - - -async def assert_pre_submit_evidence_immutable(connection): - """Reject edits, deletes and late appends to the existing canonical evidence.""" - immutable_statements = ( - "update pre_submit_evidence_sets set terminal_status='blocked'", - "update pre_submit_evidence_results set status='failed'", - "delete from pre_submit_evidence_results", - "truncate pre_submit_evidence_results", - "insert into pre_submit_evidence_results " - "select '00000000-0000-7000-8000-000000000001'," - "evidence_set_id,result_order+1000," - "schema_version,dispatch_authority,definition_id || '.forged'," - "definition_version,public_name,source,phase,classification,severity,status," - "failure_code,message_code,effective_plan_sha256,rule_instance_id," - "locked_policy_sha256,now(),checker_order,metadata_json " - "from pre_submit_evidence_results limit 1", - ) - for statement in immutable_statements: - with pytest.raises(DBAPIError): - async with connection.begin_nested(): - await connection.execute(text(statement)) - with pytest.raises(DBAPIError, match="pre_submit_evidence_sets rows are immutable"): - async with connection.begin_nested(): - await connection.execute( - text( - "insert into pre_submit_evidence_sets select " - "(jsonb_populate_record(null::pre_submit_evidence_sets, " - "to_jsonb(existing_row) || jsonb_build_object(" - "'id','00000000-0000-7000-8000-000000000003'," - "'operation_identity','sha256:' || repeat('e',64)," - "'attempt_id',null,'attempt_request_digest',null," - "'created_at',transaction_timestamp()))).* " - "from pre_submit_evidence_sets existing_row limit 1" - ) - ) - await connection.execute( - text( - "delete from pre_submit_evidence_sets " - "where id='00000000-0000-7000-8000-000000000003'" - ) - ) - with pytest.raises(DBAPIError, match="pre_submit_evidence_sets rows are immutable"): - async with connection.begin_nested(): - await connection.execute(text("truncate pre_submit_evidence_sets cascade")) - with pytest.raises(DBAPIError, match="creation timestamp is invalid"): - async with connection.begin_nested(): - await connection.execute( - text( - "insert into pre_submit_evidence_sets select " - "(jsonb_populate_record(null::pre_submit_evidence_sets, " - "to_jsonb(existing_row) || jsonb_build_object(" - "'id','00000000-0000-7000-8000-000000000002'," - "'operation_identity','sha256:' || repeat('f',64)," - "'created_at',existing_row.created_at - interval '1 day'))).* " - "from pre_submit_evidence_sets existing_row limit 1" - ) - ) - - -async def assert_admission_replay_state(session, evidence_id, attempt_id, admission_id, status): - """Both recovery reads expose an admission ID only while its real SQL row is ready.""" - from app.modules.artifacts.submission_admission import ( - PreparedSubmissionBundlePreparationCommand, current_submission_bundle_admission_id, - ) - command = PreparedSubmissionBundlePreparationCommand( - session=session, authority=SimpleNamespace(), task_contexts=SimpleNamespace(), - project_contexts=SimpleNamespace(), runtime_factory=None, - ) - result = await command._existing_durable_result(evidence_id) - assert result.submission_bundle_preparation_status == status - assert result.admission_id == (UUID(admission_id) if status == "ready" else None) - async with session.begin(): - current = await current_submission_bundle_admission_id(session, put_attempt_id=attempt_id) - assert current == (UUID(admission_id) if status == "ready" else None) - - -def durable_put_service(session, settings, namespace, storage, authorization): - """Compose final ART handoff with the same transaction-bound canonical owners.""" - from app.modules.artifacts.service import ArtifactAdmissionService - from app.modules.artifacts.submission_admission import SubmissionBundleDurablePutService - - return SubmissionBundleDurablePutService( - session=session, admission=ArtifactAdmissionService(session, settings, namespace), - storage=storage, authorization=authorization, - task_contexts=task_submission_context_port(session), - project_contexts=ProjectLockedPolicyRepository(session), - ) diff --git a/backend/tests/project_role_issue_fixtures.py b/backend/tests/project_role_issue_fixtures.py new file mode 100644 index 000000000..4f163e389 --- /dev/null +++ b/backend/tests/project_role_issue_fixtures.py @@ -0,0 +1,78 @@ +"""Shared real role-issuance helpers independent of retired checker execution.""" + +from uuid import UUID + +from app.core.identifiers import new_record_id +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.actors.service import ResolvedActor +from app.modules.authorization import router as authorization_router +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.project_role_schemas import ProjectRoleGrantIssueBody +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + HumanAuthorizationContext, + IdentityLinkStatus, +) + + +def issue_body(target_actor_id: UUID) -> ProjectRoleGrantIssueBody: + """Build the exact bounded reviewer grant request used by concurrency tests.""" + return ProjectRoleGrantIssueBody.model_validate({ + "target_actor_profile_id": target_actor_id, + "role": "reviewer", + "reason": "Concurrent project-role issue lock-order proof", + "qualification": { + "skills_snapshot": { + "availability": "available", + "reference_ids": ["skill:reviewer"], + "unavailable_reason": None, + }, + "reputation_snapshot": { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + "prior_project_work_refs": [], + "external_expertise_refs": [], + }, + }) + + +async def issue_runtime(session, actor_id: UUID, link_id: UUID): + """Compose the real prepared role authority for an existing manager.""" + context = HumanAuthorizationContext( + actor_profile_id=actor_id, + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=link_id, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=new_record_id(), + correlation_id=new_record_id(), + ) + repository = AdminAuthorizationRepository(session) + authority = AuthorizationService(session, context, admin_repository=repository) + prepared = PreparedAuthorizationService(session, context, authority, repository) + profile = await session.get(ActorProfile, str(actor_id)) + link = await session.get(ActorIdentityLink, str(link_id)) + assert profile is not None and link is not None + await session.commit() + return prepared, ResolvedActor(profile, link) + + +async def issue_role(session, project_id, body, issue_key, resolved, prepared): + """Invoke the actual project-role route owner and preserve rollback behavior.""" + try: + return await authorization_router.issue_project_role_grant( + project_id=project_id, + payload=body, + idempotency_key=issue_key, + resolved=resolved, + prepared=prepared, + session=session, + ) + except BaseException: + await session.rollback() + raise diff --git a/backend/tests/projects/guide_activation/pg_support.py b/backend/tests/projects/guide_activation/pg_support.py index 7a1ba6e95..bd7e36189 100644 --- a/backend/tests/projects/guide_activation/pg_support.py +++ b/backend/tests/projects/guide_activation/pg_support.py @@ -125,20 +125,17 @@ async def validate_replay(self, facts, decision_event_id): def activation_service(session, actor, command, grant, *, authority=True, close_error=False): - from app.adapters.checkers import project_guide_approval_compiler + from app.adapters.checkers import external_checker_registry_reader from app.adapters.contributions import contribution_policy_validation_port from app.adapters.projects.contribution_validation import GuideContributionPolicyValidation from app.modules.projects.guide_activation.service import GuideActivationService - planner, pre, post = project_guide_approval_compiler() return GuideActivationService( session, contribution=GuideContributionPolicyValidation( contribution_policy_validation_port(session) ), - planner=planner, - pre_catalogue=pre, - post_catalogue=post, + registry=external_checker_registry_reader(session), authorization=ActivationAuthority( session, actor, command.target.proposal.project_id, grant, close_error=close_error ) @@ -149,11 +146,11 @@ def activation_service(session, actor, command, grant, *, authority=True, close_ @asynccontextmanager async def activation_case( - url, *, human_review_required=True, compensated=False, contribution_awards=() + url, *, human_review_required=True, compensated=False, contribution_awards=(), post_submit_registry_spec=None ): from .source_fixtures import source_case - async with source_case(url) as (values, factory, finalization, actor, grant): + async with source_case(url, post_submit_registry_spec=post_submit_registry_spec) as (values, factory, finalization, actor, grant): await seed_selected_review_revision_inputs( factory, finalization, actor, human_review_required=human_review_required ) @@ -242,14 +239,19 @@ async def publish_policy(factory, project_id, *, compensated=False, contribution award_bindings = () if contribution_awards: from .compensation_fixtures import create_binding - award_bindings = tuple([ - (instrument, await create_binding(factory, world, instrument_type=instrument)) - for instrument in contribution_awards - ]) + + award_bindings = tuple( + [ + (instrument, await create_binding(factory, world, instrument_type=instrument)) + for instrument in contribution_awards + ] + ) policy = None for operation in ("create_draft", "update_draft", "publish"): async with factory() as session, session.begin(): policy = await getattr(world.service(session), operation)( - world.request(operation, policy, compensated=compensated, award_bindings=award_bindings) + world.request( + operation, policy, compensated=compensated, award_bindings=award_bindings + ) ) return world, policy diff --git a/backend/tests/projects/guide_activation/source_fixtures.py b/backend/tests/projects/guide_activation/source_fixtures.py index 69285c725..72d991b09 100644 --- a/backend/tests/projects/guide_activation/source_fixtures.py +++ b/backend/tests/projects/guide_activation/source_fixtures.py @@ -8,6 +8,7 @@ from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.service import ResolvedActor +from app.core.identifiers import new_record_id from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.authorization.repository import AdminAuthorizationRepository @@ -26,7 +27,6 @@ seed_setup_service_for_compiled_fixture, ) from tests.project_create_fixtures import seed_authorized_project -from tests.migration_fixtures import current_art_attempt_seed_schema from tests.projects.guide_compilation.helpers import ids, context, result from tests.projects.guide_compilation.finalization.pg_prerequisites import ( compilation_and_projections, @@ -35,8 +35,7 @@ from tests.projects.guide_compilation.proposals.pg_support import seed_review_actor -async def create_compiled_guide(factory, values, actor, *, version="v1", artifact_proposal=None): - """Create declarations with real AUTH, then execute/project/finalize scripted findings.""" +async def _create_guide_source(factory, values, actor, version): async with factory() as session, session.begin(): resolved = ResolvedActor( await session.get(ActorProfile, str(actor.actor_profile_id)), @@ -70,7 +69,76 @@ async def create_compiled_guide(factory, values, actor, *, version="v1", artifac ), ) setup = await session.get(ProjectSetupRun, str(created.response.setup.id)) - snapshot_id = setup.source_snapshot_id + return setup.source_snapshot_id + + +def _external_compilation_outcome(outcome, registry_entries, pre_entry, post_entry, refs): + if not registry_entries: + return outcome + from app.interfaces.project_agents import ( + AtomicGuideRequirement, + PostSubmissionBindingProposal, + PreSubmissionBindingProposal, + ) + + requirements, pre_bindings, post_bindings = [], [], [] + if pre_entry is not None: + requirements.append( + AtomicGuideRequirement( + requirement_id="project.archive_rules", + statement="Validate the submitted project archive rules.", + disposition="supported_pre_submit", + evidence_refs=refs, + ) + ) + pre_bindings.append( + PreSubmissionBindingProposal( + requirement_id="project.archive_rules", + capability_id=pre_entry.capability_id, + capability_version=pre_entry.capability_version, + stage="pre_submit", + ) + ) + if post_entry is not None: + requirements.append( + AtomicGuideRequirement( + requirement_id="project.post_evidence", + statement="Evaluate the submitted evidence package.", + disposition="supported_post_submit", + evidence_refs=refs, + ) + ) + post_bindings.append( + PostSubmissionBindingProposal( + requirement_id="project.post_evidence", + capability_id=post_entry.capability_id, + capability_version=post_entry.capability_version, + stage="post_submit", + parameters=(), + ) + ) + return outcome.model_copy( + update={ + "requirements": tuple(requirements), + "pre_submit_bindings": tuple(pre_bindings), + "post_submit_bindings": tuple(post_bindings), + } + ) + + +async def create_compiled_guide( + factory, + values, + actor, + *, + version="v1", + artifact_proposal=None, + pre_submit_registry_entry=None, + post_submit_registry_entry=None, + compilation_outcome=None, +): + """Create declarations with real AUTH, then execute/project/finalize scripted findings.""" + snapshot_id = await _create_guide_source(factory, values, actor, version) manifest = await create_committed_document_fixture(str(snapshot_id), sessions=factory) # Arrange the completed durable dispatch prerequisite; no broker is exercised here. async with factory() as session, session.begin(): @@ -85,10 +153,22 @@ async def create_compiled_guide(factory, values, actor, *, version="v1", artifac "snapshot": manifest.source_snapshot_id, "setup_1": manifest.setup_run_id, } - compilation_context = context(values, guide_version=version).model_copy( - update={"material": manifest} + checker_capabilities = None + registry_entries = tuple( + entry + for entry in (pre_submit_registry_entry, post_submit_registry_entry) + if entry is not None ) - outcome = result() + if registry_entries: + from tests.checkers.external.support import capability_projection + + checker_capabilities = capability_projection(*registry_entries) + compilation_context = context( + values, + guide_version=version, + checker_capabilities=checker_capabilities, + ).model_copy(update={"material": manifest}) + outcome = compilation_outcome or result() if artifact_proposal is not None: outcome = outcome.model_copy(update={"submission_artifact_policy": artifact_proposal}) refs = tuple( @@ -100,7 +180,19 @@ async def create_compiled_guide(factory, values, actor, *, version="v1", artifac for item in manifest.documents ) outcome = outcome.model_copy( - update={"findings": (outcome.findings[0].model_copy(update={"evidence_refs": refs}),)} + update={ + field: tuple( + item.model_copy(update={"evidence_refs": refs}) for item in getattr(outcome, field) + ) + for field in ("findings", "requirements", "capability_suggestions") + } + ) + outcome = _external_compilation_outcome( + outcome, + registry_entries, + pre_submit_registry_entry, + post_submit_registry_entry, + refs, ) url = factory.kw["bind"].url.render_as_string(hide_password=False) finalization = await compilation_and_projections( @@ -115,22 +207,36 @@ async def create_compiled_guide(factory, values, actor, *, version="v1", artifac @asynccontextmanager -async def source_case(url, *, namespace=None, guide_version="v1", artifact_proposal=None): +async def source_case( + url, + *, + namespace=None, + guide_version="v1", + artifact_proposal=None, + pre_submit_registry_spec=None, + post_submit_registry_spec=None, + compilation_outcome=None, +): """An authorized draft Project plus a genuinely created complete guide source.""" engine = create_async_engine(url) factory = async_sessionmaker(engine, expire_on_commit=False) try: if namespace is not None: from app.modules.artifacts.models import ArtifactStorageNamespace + async with factory() as session, session.begin(): existing = await session.get(ArtifactStorageNamespace, "primary") if existing is None: - session.add(ArtifactStorageNamespace( - id="primary", backend=namespace.backend, adapter=namespace.adapter, - provider_profile=namespace.provider_profile, - namespace_descriptor=namespace.namespace_descriptor, - namespace_fingerprint=namespace.namespace_fingerprint, - )) + session.add( + ArtifactStorageNamespace( + id="primary", + backend=namespace.backend, + adapter=namespace.adapter, + provider_profile=namespace.provider_profile, + namespace_descriptor=namespace.namespace_descriptor, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) else: assert existing.namespace_fingerprint == namespace.namespace_fingerprint values = ids() @@ -152,9 +258,87 @@ async def source_case(url, *, namespace=None, guide_version="v1", artifact_propo ).one() values.update(actor=UUID(service_id), link=UUID(link_id)) actor, grant = await seed_review_actor(factory, values["project"]) - async with current_art_attempt_seed_schema(url): - values, finalization = await create_compiled_guide(factory, values, actor, version=guide_version, - artifact_proposal=artifact_proposal) + pre_registry_entry = ( + await _register_external_checker_fixture(factory, pre_submit_registry_spec) + if pre_submit_registry_spec is not None + else None + ) + post_registry_entry = ( + await _register_external_checker_fixture(factory, post_submit_registry_spec) + if post_submit_registry_spec is not None + else None + ) + values, finalization = await create_compiled_guide( + factory, + values, + actor, + version=guide_version, + artifact_proposal=artifact_proposal, + pre_submit_registry_entry=pre_registry_entry, + post_submit_registry_entry=post_registry_entry, + compilation_outcome=compilation_outcome, + ) yield values, factory, finalization, actor, grant finally: await engine.dispose() + + +async def _register_external_checker_fixture(factory, spec): + """Publish one real Operator-authorized immutable entry before compilation.""" + from app.modules.authorization.checker_registry_authorization import ( + ExternalCheckerRegistryAuthorizationAdapter, + ) + from app.modules.checkers.api.external import make_external_checker_registration_request + from app.modules.checkers.external_registry import ( + ExternalCheckerRegistryReader, ExternalCheckerRegistryService, + ) + from tests.project_create_fixtures import ( + grant_fixture_admin_role, + seed_fixture_actor, + ) + + async with factory() as session, session.begin(): + reader = ExternalCheckerRegistryReader(session) + existing = tuple( + entry for entry in await reader.list_entries() + if (entry.capability_id, entry.capability_version, entry.phase) + == (spec.capability_id, spec.capability_version, spec.phase) + ) + if existing: + assert len(existing) == 1 and existing[0].entry_digest == spec.spec_digest + return await reader.read_exact( + UUID(str(existing[0].registry_entry_id)), existing[0].entry_digest, + ) + profile, link = await seed_fixture_actor( + session, + issuer="https://external-checker-fixture.test", + subject_prefix="external-checker-operator", + ) + await grant_fixture_admin_role( + session, + profile.id, + role="operator", + scope="system", + ) + operation_id = new_record_id() + request = make_external_checker_registration_request( + actor_profile_id=UUID(profile.id), + operation_id=operation_id, + registry_entry_id=new_record_id(), + spec=spec, + ) + authority_context = HumanAuthorizationContext( + actor_profile_id=UUID(profile.id), + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=UUID(link.id), + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), + correlation_id=operation_id, + ) + return await ExternalCheckerRegistryService( + session, + ExternalCheckerRegistryAuthorizationAdapter( + AuthorizationService(session, authority_context) + ), + ).register(request) diff --git a/backend/tests/projects/guide_activation/test_admission.py b/backend/tests/projects/guide_activation/test_admission.py index 07e7c8c1c..329bec9ef 100644 --- a/backend/tests/projects/guide_activation/test_admission.py +++ b/backend/tests/projects/guide_activation/test_admission.py @@ -43,9 +43,6 @@ async def lock_activation_scope(self, locator): service = GuideActivationService( session, contribution=None, - planner=None, - pre_catalogue=None, - post_catalogue=None, authorization=Authority(), ) with pytest.raises(GuideProposalError, match="authority_unavailable"): diff --git a/backend/tests/projects/guide_activation/test_contracts.py b/backend/tests/projects/guide_activation/test_contracts.py index 4c51467a7..17bf4daf5 100644 --- a/backend/tests/projects/guide_activation/test_contracts.py +++ b/backend/tests/projects/guide_activation/test_contracts.py @@ -20,11 +20,90 @@ from app.modules.projects.api.guide_proposals import ( GuideProposalTarget, GuideProposalApprovalReceipt, + GuideProposalError, + MissingCheckerCapability, ) from app.modules.projects.api.post_policy import PostPolicyTarget from tests.projects.guide_compilation.proposals.contract_support import HASH, target_values +@pytest.mark.parametrize("field,value", [ + ("requirement_id", "../private"), + ("stage", "review"), + ("title", ""), + ("title", "x" * 1001), + ("provider_coordinates", "private"), +]) +def test_missing_capability_feedback_is_closed_and_bounded(field, value): + values = dict(requirement_id="required.tests", stage="pre_submit", title="Required tests") + values[field] = value + with pytest.raises(ValidationError): + MissingCheckerCapability(**values) + + +def test_missing_capabilities_cannot_be_attached_to_concealed_errors_or_exceed_bound(): + item = MissingCheckerCapability( + requirement_id="required.tests", stage="pre_submit", title="Required tests" + ) + for code, capabilities in ( + ("proposal_unavailable", (item,)), + ("authority_unavailable", (item,)), + ("approval_blocked", (item,) * 201), + ): + with pytest.raises(ValueError, match="invalid missing capability refusal"): + GuideProposalError(code, missing_capabilities=capabilities) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ( + "approval_blocked", "authority_unavailable", "proposal_unavailable", "foreign_project", +)) +async def test_public_activation_refusal_projects_only_authorized_missing_capabilities(failure): + from httpx import ASGITransport, AsyncClient + from app.api.deps.authorization import enforce_human_authorization_read + from app.api.deps.guide_activation import ActivationRequest, get_activation_request + from app.main import create_app + from app.modules.authorization.api import ActorIdentityFacts, ActorKind + + items = tuple(MissingCheckerCapability( + requirement_id=f"required.{stage}", stage=stage, title=f"Required {stage} checker", + ) for stage in ("pre_submit", "post_submit")) + error = GuideProposalError( + "proposal_unavailable" if failure == "foreign_project" else failure, + missing_capabilities=items if failure == "approval_blocked" else (), + ) + session = SimpleNamespace(commit=AsyncMock()) + service = SimpleNamespace(activate=AsyncMock(side_effect=error)) + actor = ActorIdentityFacts( + actor_profile_id=uuid4(), identity_link_id=uuid4(), actor_kind=ActorKind.HUMAN, + ) + async def dependency(): + return ActivationRequest(session, service, actor, uuid4()) + + app = create_app() + app.dependency_overrides[enforce_human_authorization_read] = lambda: None + app.dependency_overrides[get_activation_request] = dependency + command = GuideActivationCommand(**command_values()) + target = command.target.proposal + project_id = uuid4() if failure == "foreign_project" else target.project_id + path = f"/api/v1/projects/{project_id}/guides/{target.guide_id}/activate" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + path, json=command.model_dump(mode="json", exclude={"idempotency_key"}), + headers={"Idempotency-Key": str(command.idempotency_key)}, + ) + expected_details = { + "missing_capabilities": [item.model_dump(mode="json") for item in items] + } if failure == "approval_blocked" else {} + assert response.status_code == (409 if failure == "approval_blocked" else 404) + assert response.json()["error"]["details"] == expected_details + session.commit.assert_not_awaited() + if failure == "foreign_project": + service.activate.assert_not_awaited() + else: + service.activate.assert_awaited_once() + + def command_values(): proposal = GuideProposalTarget(**target_values()) upstream = GuideProposalApprovalReceipt( diff --git a/backend/tests/projects/guide_activation/test_postgresql.py b/backend/tests/projects/guide_activation/test_postgresql.py index 2ced53190..f6a88fdcb 100644 --- a/backend/tests/projects/guide_activation/test_postgresql.py +++ b/backend/tests/projects/guide_activation/test_postgresql.py @@ -11,6 +11,115 @@ from .pg_support import activation_case, activation_service +async def _activation_effects(session): + """Capture lifecycle, activation replay, release and dispatch facts exactly.""" + return { + table: tuple(await session.scalars(text( + f"SELECT to_jsonb(r)::text FROM public.{table} r ORDER BY to_jsonb(r)::text" + ))) + for table in ( + "projects", "project_guides", "guide_mutation_idempotency_records", + "workstream_tasks", "submissions", "outbox_events", + ) + } + + +def _capability_outcome(*, supported): + """Return the same requirement before and after exact capability coverage.""" + from app.interfaces.project_agents import ( + AtomicGuideRequirement, + CapabilitySuggestion, + PreSubmissionBindingProposal, + ) + from tests.projects.guide_compilation.helpers import result + + outcome = result() + refs = outcome.findings[0].evidence_refs + requirement = AtomicGuideRequirement( + requirement_id="specialist_archive", + statement="Inspect the specialist archive before admission.", + disposition="supported_pre_submit" if supported else "pre_submit_capability_gap", + evidence_refs=refs, + ) + return outcome.model_copy( + update={ + "status": "draft_ready" if supported else "draft_ready_with_warnings", + "findings": ( + outcome.findings[0].model_copy( + update={ + "severity": "info" if supported else "warning", + "code": "guide.ready" if supported else "checker.capability_gap", + "message": ( + "Exact checker coverage is available." + if supported + else "Register and bind the required checker capability." + ), + } + ), + ), + "requirements": (requirement,), + "pre_submit_bindings": ( + PreSubmissionBindingProposal( + requirement_id=requirement.requirement_id, + capability_id="acme.safe_archive", + capability_version="v1.2.3", + stage="pre_submit", + ), + ) + if supported + else (), + "capability_suggestions": () + if supported + else ( + CapabilitySuggestion( + requirement_id=requirement.requirement_id, + stage="pre_submit", + title="Specialist archive checker", + rationale="The required immutable checker is not registered yet.", + evidence_refs=refs, + ), + ), + } + ) + + +async def _activation_inputs(factory, finalization, approval, actor, grant, values, policy): + """Approve the exact derived POST policy and return one activation command.""" + from app.core.hashing import canonical_json_hash + from app.modules.projects.api.guide_proposals import GuideProposalSelection + from app.modules.projects.api.post_policy import PostPolicyApproval + from app.modules.projects.api.post_policy import PostPolicyDerive + from tests.projects.guide_compilation.helpers import service_actor + from tests.projects.post_policy.pg_support import operate + from .pg_support import activation_command + + derived = await operate( + factory, + service_actor(values), + finalization.project_id, + None, + "derive", + PostPolicyDerive( + selection=GuideProposalSelection( + project_id=finalization.project_id, + guide_id=finalization.guide_id, + compilation_id=finalization.compilation_id, + ), + upstream_approval_operation_id=approval.operation_id, + upstream_approval_output_digest=canonical_json_hash(approval.model_dump(mode="json")), + ), + ) + approved = await operate( + factory, + actor, + finalization.project_id, + grant, + "approve", + PostPolicyApproval(target=derived.target, idempotency_key=uuid4()), + ) + return await activation_command(factory, approved, policy) + + async def test_complete_activation_and_exact_replay(clean_postgres_database): async with activation_case(clean_postgres_database) as ( factory, @@ -96,3 +205,97 @@ async def test_activation_default_authority_is_unavailable(clean_postgres_databa ) guide = await session.get(ProjectGuide, str(command.target.proposal.guide_id)) assert guide.status == "draft" and guide.activation_operation_id is None + + +async def test_checker_registration_requires_corrected_approval_before_activation( + clean_postgres_database, +): + """F-020 closes only after register, recompile, approve and exact activation.""" + from app.modules.projects.api.guide_proposals import ( + GuideProposalApproval, + GuideProposalCorrection, + ) + from tests.checkers.external.support import registry_spec + from tests.projects.guide_compilation.proposals.pg_support import ( + finalize_corrected_attempt, + read_package, + seed_selected_review_revision_inputs, + ) + from tests.projects.guide_compilation.proposals.test_postgresql import approve, correct + from .pg_support import publish_policy + from .source_fixtures import _register_external_checker_fixture, source_case + + gap, covered = _capability_outcome(supported=False), _capability_outcome(supported=True) + async with source_case( + clean_postgres_database, + compilation_outcome=gap, + ) as (values, factory, initial, actor, grant): + package = await read_package(factory, initial, actor, grant) + first = await approve( + factory, + initial, + actor, + grant, + GuideProposalApproval( + target=package.target, + acknowledged_warning_hashes=package.warning_hashes, + idempotency_key=uuid4(), + ), + ) + await _register_external_checker_fixture(factory, registry_spec("pre_submit")) + _, policy = await publish_policy(factory, initial.project_id) + await seed_selected_review_revision_inputs(factory, initial, actor) + stale = await _activation_inputs(factory, initial, first, actor, grant, values, policy) + async with factory() as session, session.begin(): + before = await _activation_effects(session) + with pytest.raises(GuideProposalError, match="approval_blocked") as refusal: + await activation_service(session, actor, stale, grant).activate( + stale, actor=actor, request_id=uuid4() + ) + assert [item.model_dump(mode="json") for item in refusal.value.missing_capabilities] == [ + dict(requirement_id=item.requirement_id, stage=item.stage, title=item.title) + for item in gap.capability_suggestions + ] + guide = await session.get(ProjectGuide, str(stale.target.proposal.guide_id)) + assert guide.status == "draft" and guide.activation_operation_id is None + assert await _activation_effects(session) == before + + correction = await correct( + factory, + initial, + actor, + grant, + GuideProposalCorrection( + target=package.target, + reason="Bind the registered checker to the required pre-admission stage.", + idempotency_key=uuid4(), + ), + ) + successor = await finalize_corrected_attempt( + factory, values, actor, correction, outcome=covered + ) + revised = await read_package(factory, successor, actor, grant) + second = await approve( + factory, + successor, + actor, + grant, + GuideProposalApproval( + target=revised.target, + expected_previous_approval_operation_id=first.operation_id, + expected_previous_approval_output_digest=revised.current_approval_output_digest, + idempotency_key=uuid4(), + ), + ) + assert second.pre_submit_bundle_hash != first.pre_submit_bundle_hash + command = await _activation_inputs(factory, successor, second, actor, grant, values, policy) + async with factory() as session, session.begin(): + receipt = await activation_service(session, actor, command, grant).activate( + command, actor=actor, request_id=uuid4() + ) + assert receipt.command.target.proposal.compilation_id == successor.compilation_id + async with factory() as session, session.begin(): + with pytest.raises(GuideProposalError, match="proposal_(stale|unavailable)"): + await activation_service(session, actor, stale, grant).activate( + stale, actor=actor, request_id=uuid4() + ) diff --git a/backend/tests/projects/guide_compilation/finalization/pg_prerequisites.py b/backend/tests/projects/guide_compilation/finalization/pg_prerequisites.py index 92d3c0eda..aca7243bc 100644 --- a/backend/tests/projects/guide_compilation/finalization/pg_prerequisites.py +++ b/backend/tests/projects/guide_compilation/finalization/pg_prerequisites.py @@ -153,7 +153,15 @@ async def compilation_and_projections( ProjectGuideCompilationExecutionCommand, ProjectGuideCompilationExecutionClassification, ) runtime = _Runtime(outcome) - receipt = await _port(factory, runtime).execute( + receipt = await _port( + factory, + runtime, + checker_capabilities=( + compilation_context.checker_capabilities + if compilation_context.checker_capabilities.definitions + else None + ), + ).execute( ProjectGuideCompilationExecutionCommand(attempt_id=requested.attempt_id) ) assert receipt.classification is ProjectGuideCompilationExecutionClassification.PERSISTED diff --git a/backend/tests/projects/guide_compilation/finalization/pg_support.py b/backend/tests/projects/guide_compilation/finalization/pg_support.py index 6468a738e..3279ee67b 100644 --- a/backend/tests/projects/guide_compilation/finalization/pg_support.py +++ b/backend/tests/projects/guide_compilation/finalization/pg_support.py @@ -182,14 +182,26 @@ async def prepare_setup_finalization(self, locator): @asynccontextmanager -async def database_case(url, *, classification="draft_ready", project=True, guide_version="v1", outcome=None): +async def database_case( + url, + *, + classification="draft_ready", + project=True, + guide_version="v1", + outcome=None, + checker_capabilities=None, +): values = await seed_database(url, guide_version=guide_version) engine = create_async_engine(url) factory = async_sessionmaker(engine, expire_on_commit=False) try: command = await compilation_and_projections( url, factory, values, classification=classification, project=project, - compilation_context=context(values, guide_version=guide_version), + compilation_context=context( + values, + guide_version=guide_version, + checker_capabilities=checker_capabilities, + ), outcome=outcome ) yield values, factory, command diff --git a/backend/tests/projects/guide_compilation/helpers.py b/backend/tests/projects/guide_compilation/helpers.py index 7e49458b7..1da5900f3 100644 --- a/backend/tests/projects/guide_compilation/helpers.py +++ b/backend/tests/projects/guide_compilation/helpers.py @@ -2,8 +2,6 @@ from __future__ import annotations -from app.modules.checkers.catalogue import project_guide_pre_submission_capabilities - from dataclasses import replace import hashlib import json @@ -19,14 +17,20 @@ from app.db import models as _all_models # noqa: F401 from app.modules.artifacts.models import ( - ArtifactContent, ArtifactReplica, ArtifactStorageNamespace, ArtifactPutAttempt, + ArtifactContent, + ArtifactReplica, + ArtifactStorageNamespace, + ArtifactPutAttempt, ArtifactOperationReceipt, ) from app.modules.projects.models import GuideSourceArtifactIngest from app.modules.projects.api.guide_documents import GuideDocumentManifest, GuideDocumentVersion from app.interfaces.project_agents import ( - CompilationFinding, GuideEvidenceRef, ProjectGuideCompilationContext, - ProjectGuideCompilationResult, SubmissionArtifactPolicyProposal, + CompilationFinding, + GuideEvidenceRef, + ProjectGuideCompilationContext, + ProjectGuideCompilationResult, + SubmissionArtifactPolicyProposal, ) from app.modules.authorization.api import ( ActorIdentityFacts, @@ -34,14 +38,11 @@ ProjectGuideCompilationExecutePersistFacts, project_guide_compilation_execute_resource_digest, ) -from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, -) from app.modules.projects.guide_compilation.contracts import ( CompilationAttemptIdentity, accepted_compilation_result, ) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue +from tests.checkers.external.support import capability_projection from app.modules.projects.api.setup_identity import project_guide_compilation_task_id from app.core.hashing import canonical_json_hash @@ -49,7 +50,10 @@ from app.modules.projects.api.task_examples import task_examples_hash, validate_task_examples TASK_EXAMPLES = validate_task_examples([{"content": "Review a claim using the project guide."}]) -TASK_EXAMPLE_MANIFEST = {"task_examples_hash": task_examples_hash(TASK_EXAMPLES), "task_examples_count": len(TASK_EXAMPLES)} +TASK_EXAMPLE_MANIFEST = { + "task_examples_hash": task_examples_hash(TASK_EXAMPLES), + "task_examples_count": len(TASK_EXAMPLES), +} SHA256 = canonical_json_hash(TASK_EXAMPLE_MANIFEST) SOURCE_ITEM_ID = new_record_id() DOCUMENT_VERSION_ID = new_record_id() @@ -98,18 +102,37 @@ def runtime_configuration(): ) -def context(values: dict[str, UUID], *, generation: int = 1, guide_version="v1") -> ProjectGuideCompilationContext: +def context( + values: dict[str, UUID], + *, + generation: int = 1, + guide_version="v1", + checker_capabilities=None, +) -> ProjectGuideCompilationContext: """Build one exact committed-original metadata context.""" material = GuideDocumentManifest( - project_id=values["project"], guide_id=values["guide"], guide_version=guide_version, - source_snapshot_id=values["snapshot"], source_snapshot_hash=SHA256, - setup_run_id=values[f"setup_{generation}"], setup_generation=generation, - documents=(GuideDocumentVersion( - source_item_id=SOURCE_ITEM_ID, ingest_id=DOCUMENT_VERSION_ID, item_order=0, - put_attempt_id=PUT_ATTEMPT_ID, content_id=CONTENT_ID, replica_id=REPLICA_ID, - storage_namespace_id="primary", namespace_fingerprint=SOURCE_SHA256, - sha256=SOURCE_SHA256, byte_count=len(SOURCE_BYTES), media_type="application/pdf", - ),), + project_id=values["project"], + guide_id=values["guide"], + guide_version=guide_version, + source_snapshot_id=values["snapshot"], + source_snapshot_hash=SHA256, + setup_run_id=values[f"setup_{generation}"], + setup_generation=generation, + documents=( + GuideDocumentVersion( + source_item_id=SOURCE_ITEM_ID, + ingest_id=DOCUMENT_VERSION_ID, + item_order=0, + put_attempt_id=PUT_ATTEMPT_ID, + content_id=CONTENT_ID, + replica_id=REPLICA_ID, + storage_namespace_id="primary", + namespace_fingerprint=SOURCE_SHA256, + sha256=SOURCE_SHA256, + byte_count=len(SOURCE_BYTES), + media_type="application/pdf", + ), + ), ) return ProjectGuideCompilationContext( task_examples=({"content": "Review a claim using the project guide."},), @@ -120,10 +143,7 @@ def context(values: dict[str, UUID], *, generation: int = 1, guide_version="v1") instruction_version="v1", agent_identity="project-guide-compilation-agent-v1", agent_version="v1", - pre_submission_capabilities=project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ), - post_submission_capabilities=current_post_submit_catalogue(), + checker_capabilities=checker_capabilities or capability_projection(), ) @@ -132,9 +152,18 @@ def result() -> ProjectGuideCompilationResult: return ProjectGuideCompilationResult( status="draft_ready", findings=( - CompilationFinding(severity="info", code="guide.ready", message="Guide is complete.", - evidence_refs=(GuideEvidenceRef(source_item_id=SOURCE_ITEM_ID, - document_version_id=DOCUMENT_VERSION_ID, sha256=SOURCE_SHA256),)), + CompilationFinding( + severity="info", + code="guide.ready", + message="Guide is complete.", + evidence_refs=( + GuideEvidenceRef( + source_item_id=SOURCE_ITEM_ID, + document_version_id=DOCUMENT_VERSION_ID, + sha256=SOURCE_SHA256, + ), + ), + ), ), submission_artifact_policy=SubmissionArtifactPolicyProposal( maximum_file_size_bytes=1_000, @@ -326,53 +355,103 @@ async def _seed_snapshot_item(engine: AsyncEngine, values: dict[str, UUID]) -> N ) -async def _seed_artifact_custody(session: AsyncSession, values: dict[str, UUID], namespace=None) -> None: +async def _seed_artifact_custody( + session: AsyncSession, values: dict[str, UUID], namespace=None +) -> None: from datetime import datetime, timezone + namespace_row = ArtifactStorageNamespace( - id="primary", backend=namespace.backend if namespace else "local", + 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_descriptor=namespace.namespace_descriptor + if namespace + else {"root": "guide-compilation-fixture"}, namespace_fingerprint=namespace.namespace_fingerprint if namespace else SOURCE_SHA256, ) - 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"), - ]) + 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.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=f"fixtures/{CONTENT_ID}", - verification_state="pending", availability_state="unknown", integrity_state="unknown") + 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=f"fixtures/{CONTENT_ID}", + 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, + 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)) + 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(id=str(RECEIPT_ID), contract_version=2, - put_attempt_id=put.id, guide_source_item_id=str(SOURCE_ITEM_ID), - replica_id=replica.id, operation="put", idempotency_key=put.operation_identity, - request_digest=put.request_digest, provider_object_ref=replica.provider_object_ref, - replayed=False, outcome="document_stored", attempt_number=1, - correlation_id=str(uuid4()), details=[])) + session.add( + ArtifactOperationReceipt( + id=str(RECEIPT_ID), + contract_version=2, + put_attempt_id=put.id, + guide_source_item_id=str(SOURCE_ITEM_ID), + replica_id=replica.id, + operation="put", + idempotency_key=put.operation_identity, + request_digest=put.request_digest, + provider_object_ref=replica.provider_object_ref, + replayed=False, + outcome="document_stored", + attempt_number=1, + correlation_id=str(uuid4()), + details=[], + ) + ) await session.flush() put.receipt_id = str(RECEIPT_ID) -async def seed_database(database_url: str, *, generations: int = 1, guide_version="v1", namespace=None) -> dict[str, UUID]: +async def seed_database( + database_url: str, *, generations: int = 1, guide_version="v1", namespace=None +) -> dict[str, UUID]: """Seed only canonical parent rows needed by hidden persistence tests.""" values = ids() engine = create_async_engine(database_url) diff --git a/backend/tests/projects/guide_compilation/proposals/pg_support.py b/backend/tests/projects/guide_compilation/proposals/pg_support.py index d9be3102d..b5c18a252 100644 --- a/backend/tests/projects/guide_compilation/proposals/pg_support.py +++ b/backend/tests/projects/guide_compilation/proposals/pg_support.py @@ -120,7 +120,14 @@ async def validate_replay(self, facts, decision_event_id): class ProposalAuthority: def __init__( - self, session, actor, project_id, grant_id, *, on_consume=None, close_error=False, + self, + session, + actor, + project_id, + grant_id, + *, + on_consume=None, + close_error=False, resource_override=None, ): self.session, self.actor = session, actor @@ -167,8 +174,21 @@ async def prepare_proposal_operation(self, locator): @asynccontextmanager -async def proposal_case(url, *, classification="draft_ready", guide_version="v1", outcome=None): - async with database_case(url, classification=classification, guide_version=guide_version, outcome=outcome) as (values, factory, command): +async def proposal_case( + url, + *, + classification="draft_ready", + guide_version="v1", + outcome=None, + checker_capabilities=None, +): + async with database_case( + url, + classification=classification, + guide_version=guide_version, + outcome=outcome, + checker_capabilities=checker_capabilities, + ) as (values, factory, command): await finalize(factory, values, command) async with factory() as session: row = ( @@ -218,7 +238,7 @@ async def read_package(factory, command, actor, grant_id): raise exc from exc.__context__ -async def request_corrected_attempt(factory, actor, correction): +async def request_corrected_attempt(factory, actor, correction, *, capabilities=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 ( @@ -232,11 +252,7 @@ async def request_corrected_attempt(factory, actor, correction): HumanAuthorizationContext, IdentityLinkStatus, ) - from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, - project_guide_pre_submission_capabilities, - ) - from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue + 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 @@ -259,8 +275,7 @@ async def request_corrected_attempt(factory, actor, correction): ProjectGuideCompilationAuthorizationAdapter(kernel, prepared), request_inputs=CompilationRequestInputs( guide_document_manifest_port(session), - project_guide_pre_submission_capabilities(build_pre_submission_checker_catalogue()), - current_post_submit_catalogue(), + capabilities or capability_projection(), runtime_configuration(), ), ) @@ -269,32 +284,40 @@ async def request_corrected_attempt(factory, actor, correction): ) -async def finalize_corrected_attempt(factory, values, actor, correction): +async def finalize_corrected_attempt(factory, values, actor, correction, *, outcome=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 ( - artifact_policy_projection_authorization, guide_compilation_execution_authority, - guide_compilation_request_authority, guide_sufficiency_projection_authorization, + artifact_policy_projection_authorization, + guide_compilation_execution_authority, + guide_compilation_request_authority, + guide_sufficiency_projection_authorization, setup_finalization_authorization, ) - from app.adapters.checkers import project_guide_approval_compiler + from app.adapters.checkers import external_checker_capability_projection from app.adapters.projects import project_guide_compilation_delivery_port 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.runtime_fixtures import ScriptedGuideRuntime, document_access + from tests.projects.guide_compilation.runtime_fixtures import ( + ScriptedGuideRuntime, + document_access, + ) - await request_corrected_attempt(factory, actor, correction) + async with factory() as session, session.begin(): + capabilities = await external_checker_capability_projection(session) + await request_corrected_attempt(factory, actor, correction, capabilities=capabilities) async with factory() as session: setup = await session.get(ProjectSetupRun, str(correction.successor_setup_run_id)) runtime = ScriptedGuideRuntime() - _, pre, post = project_guide_approval_compiler() + if outcome is not None: + runtime.outcome = outcome coordinator = project_guide_compilation_delivery_port( factory, material_factory=guide_document_manifest_port, document_access_factory=document_access, - pre_capabilities=pre, post_capabilities=post, + checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, configuration_factory=runtime_configuration, @@ -304,30 +327,37 @@ async def finalize_corrected_attempt(factory, values, actor, correction): finalization_authorization_factory=setup_finalization_authorization, ) delivery = ProjectGuideCompilationDelivery( - project_id=values["project"], guide_id=values["guide"], + project_id=values["project"], + guide_id=values["guide"], source_snapshot_id=setup.source_snapshot_id, setup_run_id=correction.successor_setup_run_id, setup_generation=correction.successor_setup_generation, - task_id=project_guide_compilation_task_id(str(correction.successor_setup_run_id), correction.successor_setup_generation), + task_id=project_guide_compilation_task_id( + str(correction.successor_setup_run_id), correction.successor_setup_generation + ), ) receipt = await coordinator.run(delivery) assert receipt["status"] == "policy_draft_ready", receipt assert runtime.calls == 1 from app.modules.projects.guide_compilation.models import ProjectGuideSetupFinalization + async with factory() as session: finalization = await session.get( ProjectGuideSetupFinalization, UUID(str(receipt["finalization_id"])) ) compilation_id = finalization.compilation_id return ProjectGuideSetupFinalizationCommand( - project_id=values["project"], guide_id=values["guide"], + project_id=values["project"], + guide_id=values["guide"], setup_run_id=correction.successor_setup_run_id, setup_generation=correction.successor_setup_generation, compilation_id=compilation_id, ) -async def seed_review_actor(factory, project_id, *, actor=None, role="project_manager", scope="project"): +async def seed_review_actor( + factory, project_id, *, actor=None, role="project_manager", scope="project" +): """Seed stored grant variants; request/approval authority guards remain enabled.""" from datetime import UTC, datetime from app.modules.actors.models import ActorProfile, ActorIdentityLink @@ -336,46 +366,98 @@ async def seed_review_actor(factory, project_id, *, actor=None, role="project_ma async with factory() as session, session.begin(): if actor is None: actor = ActorIdentityFacts(new_record_id(), new_record_id(), ActorKind.HUMAN) - session.add(ActorProfile(id=str(actor.actor_profile_id), actor_kind="human", status="active", - provisioning_method="automatic_first_access", created_by="proposal-fixture")) + session.add( + ActorProfile( + id=str(actor.actor_profile_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by="proposal-fixture", + ) + ) await session.flush() - session.add(ActorIdentityLink(id=str(actor.identity_link_id), actor_profile_id=str(actor.actor_profile_id), - issuer="https://identity.flowresearch.tech", subject=str(actor.actor_profile_id), - subject_kind="human", status="active", linked_by="proposal-fixture", - last_verified_at=datetime.now(UTC))) + session.add( + ActorIdentityLink( + id=str(actor.identity_link_id), + actor_profile_id=str(actor.actor_profile_id), + issuer="https://identity.flowresearch.tech", + subject=str(actor.actor_profile_id), + subject_kind="human", + status="active", + linked_by="proposal-fixture", + last_verified_at=datetime.now(UTC), + ) + ) await session.flush() - grant = await grant_fixture_admin_role(session, actor.actor_profile_id, role=role, scope=scope, - project_id=project_id if scope == "project" else None) + grant = await grant_fixture_admin_role( + session, + actor.actor_profile_id, + role=role, + scope=scope, + project_id=project_id if scope == "project" else None, + ) return actor, grant.id async def revoke_review_grant(factory, actor, grant): """Use the database's real revoke transition with an attributed administrator.""" - administrator, admin_grant = await seed_review_actor(factory, None, role="access_administrator", scope="system") + administrator, admin_grant = await seed_review_actor( + factory, None, role="access_administrator", scope="system" + ) async with factory() as session, session.begin(): - result = await session.execute(text( - "UPDATE admin_role_grants SET status='revoked',version=2,revoked_by_actor_profile_id=:actor," - "revoked_by_admin_role_grant_id=:authorizer,revoked_at=now(),revoked_reason='Manager turnover' WHERE id=:id AND target_actor_profile_id=:target" - ), {"actor": str(administrator.actor_profile_id), "authorizer": admin_grant, "id": grant, "target": str(actor.actor_profile_id)}) + result = await session.execute( + text( + "UPDATE admin_role_grants SET status='revoked',version=2,revoked_by_actor_profile_id=:actor," + "revoked_by_admin_role_grant_id=:authorizer,revoked_at=now(),revoked_reason='Manager turnover' WHERE id=:id AND target_actor_profile_id=:target" + ), + { + "actor": str(administrator.actor_profile_id), + "authorizer": admin_grant, + "id": grant, + "target": str(actor.actor_profile_id), + }, + ) assert result.rowcount == 1 -async def seed_selected_review_revision_inputs(factory, command, actor, *, human_review_required=True): +async def seed_selected_review_revision_inputs( + factory, command, actor, *, human_review_required=True +): """Select valid review/revision inputs through their authorized mutation owner.""" from app.modules.actors.models import ActorProfile, ActorIdentityLink from app.modules.actors.service import ResolvedActor from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.authorization.repository import AdminAuthorizationRepository - from app.modules.authorization.runtime import ActorStatus, HumanAuthorizationContext, IdentityLinkStatus - from app.modules.projects.policy_mutation_service import ProjectPolicyMutationService, NO_CURRENT_POLICY_ETAG + from app.modules.authorization.runtime import ( + ActorStatus, + HumanAuthorizationContext, + IdentityLinkStatus, + ) + from app.modules.projects.policy_mutation_service import ( + ProjectPolicyMutationService, + NO_CURRENT_POLICY_ETAG, + ) from app.modules.projects.schemas import ReviewPolicyInput, RevisionPolicyInput for kind, payload in ( - ("review", ReviewPolicyInput(human_review_required=human_review_required, review_preference_window_seconds=3600, review_lease_duration_seconds=1800, - allowed_decisions=["accept", "needs_revision", "reject"])), - ("revision", RevisionPolicyInput(max_revision_rounds=2, revision_deadline_hours=48, - allowed_resubmission_states=["needs_revision"])), + ( + "review", + ReviewPolicyInput( + human_review_required=human_review_required, + review_preference_window_seconds=3600, + review_lease_duration_seconds=1800, + allowed_decisions=["accept", "needs_revision", "reject"], + ), + ), + ( + "revision", + RevisionPolicyInput( + max_revision_rounds=2, + revision_deadline_hours=48, + allowed_resubmission_states=["needs_revision"], + ), + ), ): async with factory() as session: resolved = ResolvedActor( @@ -383,14 +465,30 @@ async def seed_selected_review_revision_inputs(factory, command, actor, *, human await session.get(ActorIdentityLink, str(actor.identity_link_id)), ) ctx = HumanAuthorizationContext( - actor_profile_id=actor.actor_profile_id, actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, identity_link_id=actor.identity_link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, request_id=uuid4(), correlation_id=uuid4(), + actor_profile_id=actor.actor_profile_id, + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=actor.identity_link_id, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), + correlation_id=uuid4(), ) repository = AdminAuthorizationRepository(session) kernel = AuthorizationService(session, ctx, admin_repository=repository) prepared = PreparedAuthorizationService(session, ctx, kernel, repository) service = ProjectPolicyMutationService(session) - mutate = service.replace_review_policy if kind == "review" else service.replace_revision_policy - await mutate(resolved, prepared, uuid4(), NO_CURRENT_POLICY_ETAG, command.project_id, command.guide_id, payload) + mutate = ( + service.replace_review_policy + if kind == "review" + else service.replace_revision_policy + ) + await mutate( + resolved, + prepared, + uuid4(), + NO_CURRENT_POLICY_ETAG, + command.project_id, + command.guide_id, + payload, + ) await session.commit() diff --git a/backend/tests/projects/guide_compilation/proposals/test_postgresql.py b/backend/tests/projects/guide_compilation/proposals/test_postgresql.py index f8e25f10e..312f1762d 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_postgresql.py +++ b/backend/tests/projects/guide_compilation/proposals/test_postgresql.py @@ -7,11 +7,6 @@ from app.core.identifiers import new_record_id from app.adapters.artifacts import guide_document_manifest_port -from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, - project_guide_pre_submission_capabilities, -) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.api.guide_proposals import ( GuideProposalApproval, GuideProposalCorrection, @@ -25,13 +20,18 @@ from app.modules.projects.guide_setup_continuation import retryable_compilation_dispatch_predicate from app.modules.projects.models import ProjectSetupRun from tests.projects.guide_compilation.finalization.pg_support import finalize, stored_state +from tests.checkers.external.support import capability_projection from .pg_support import ProposalAuthority, proposal_case, read_package async def approve(factory, command, actor, grant, payload): try: async with factory() as session, session.begin(): - planner = build_pre_submission_checker_catalogue() + from app.adapters.checkers import ( + external_checker_capability_projection, + external_checker_registry_reader, + ) + return await GuideProposalService( session, ProposalAuthority( @@ -40,14 +40,13 @@ async def approve(factory, command, actor, grant, payload): command.project_id, grant, ), + external_checker_registry_reader(session), ).approve( payload, actor=actor, request_id=uuid4(), material=guide_document_manifest_port(session), - pre_capabilities=project_guide_pre_submission_capabilities(planner), - post_capabilities=current_post_submit_catalogue(), - planner=planner, + capabilities=await external_checker_capability_projection(session), ) except GuideProposalError as exc: raise exc from exc.__context__ @@ -71,49 +70,51 @@ async def correct(factory, command, actor, grant, payload, *, authority_resource async def test_review_package_is_exact_complete_and_redacted(clean_postgres_database): - from app.interfaces.project_agents import ProjectGuideCompilationResult - from tests.projects.guide_compilation.helpers import result - - body = result().model_dump(mode="json") - evidence = body["findings"][0]["evidence_refs"] - body.update( - status="guide_blocked", submission_artifact_policy=None, - requirements=[ - dict(requirement_id="packet", statement="Limit artifact file size.", - disposition="supported_pre_submit", evidence_refs=evidence), - dict(requirement_id="coverage", statement="Check acceptance criteria coverage.", - disposition="supported_post_submit", evidence_refs=evidence), - dict(requirement_id="semantic", statement="Evaluate specialist correctness.", - disposition="post_submit_capability_gap", evidence_refs=evidence), - ], - pre_submit_bindings=[dict(requirement_id="packet", capability_id="policy.file_size.limit", - capability_version="v1", stage="pre_submit")], - post_submit_bindings=[dict(requirement_id="coverage", capability_id="check_acceptance_criteria_present", - capability_version="v0.1", stage="post_submit", parameters=[])], - capability_suggestions=[dict(requirement_id="semantic", stage="post_submit", - title="Specialist correctness evaluator", rationale="The catalogue lacks this evaluation.", - evidence_refs=evidence)], - setup_notes=["A project manager must review the specialist evaluation gap."], + from tests.projects.guide_compilation.test_capability_growth import ( + _context as growth_context, + growth_report, ) - body["findings"][0]["severity"] = "blocking_gap" - outcome = ProjectGuideCompilationResult.model_validate(body) - async with proposal_case(clean_postgres_database, classification="guide_blocked", outcome=outcome) as ( - _, factory, command, actor, grant - ): + + outcome = growth_report() + capabilities = growth_context().checker_capabilities + async with proposal_case( + clean_postgres_database, + classification=outcome.status, + outcome=outcome, + checker_capabilities=capabilities, + ) as (_, factory, command, actor, grant): package = await read_package(factory, command, actor, grant) assert package.target.compilation_id == command.compilation_id assert package.target.setup_run_id == command.setup_run_id assert package.current is True expected = outcome.model_dump(mode="json") + assert len(expected["pre_submit_bindings"]) == 1 + assert len(expected["post_submit_bindings"]) == 1 + assert {item["stage"] for item in expected["capability_suggestions"]} == { + "pre_submit", + "post_submit", + } for section in ("findings", "requirements", "capability_suggestions"): for item in expected[section]: - item["evidence_refs"] = [dict(document_number=1, start_page=ref["start_page"], - end_page=ref["end_page"], section=ref["section"]) - for ref in item["evidence_refs"]] + item["evidence_refs"] = [ + dict( + document_number=1, + start_page=ref["start_page"], + end_page=ref["end_page"], + section=ref["section"], + ) + for ref in item["evidence_refs"] + ] assert package.result.model_dump(mode="json") == expected serialized = package.model_dump_json() - for forbidden in ("source_item_id", "document_version_id", "provider_object_ref", - "runtime_configuration", "provider_idempotency_key", "put_attempt_id"): + for forbidden in ( + "source_item_id", + "document_version_id", + "provider_object_ref", + "runtime_configuration", + "provider_idempotency_key", + "put_attempt_id", + ): assert forbidden not in serialized @@ -234,11 +235,7 @@ async def test_correction_uses_canonical_human_request_and_binds_feedback(clean_ request_id=uuid4(), correlation_id=uuid4(), ) - planner = build_pre_submission_checker_catalogue() - pre, post = ( - project_guide_pre_submission_capabilities(planner), - current_post_submit_catalogue(), - ) + capabilities = capability_projection() receipts = [] for _ in range(2): async with factory() as session: @@ -250,8 +247,7 @@ async def test_correction_uses_canonical_human_request_and_binds_feedback(clean_ ProjectGuideCompilationAuthorizationAdapter(kernel, prepared), request_inputs=CompilationRequestInputs( guide_document_manifest_port(session), - pre, - post, + capabilities, runtime_configuration(), ), ) @@ -266,8 +262,7 @@ async def test_correction_uses_canonical_human_request_and_binds_feedback(clean_ attempt = await session.get(ProjectGuideCompilationAttempt, receipts[0].attempt_id) inputs = CompilationRequestInputs( guide_document_manifest_port(session), - pre, - post, + capabilities, runtime_configuration(), ) setup = await session.get(ProjectSetupRun, str(correction.successor_setup_run_id)) @@ -442,10 +437,14 @@ async def test_capability_close_failure_rolls_back_every_decision_effect(clean_p async with proposal_case(clean_postgres_database) as (_, factory, command, actor, grant): package = await read_package(factory, command, actor, grant) before = await stored_state(factory, command) - planner = build_pre_submission_checker_catalogue() for action in ("approve", "correct"): with pytest.raises(GuideProposalError, match="authority_unavailable"): async with factory() as session, session.begin(): + from app.adapters.checkers import ( + external_checker_capability_projection, + external_checker_registry_reader, + ) + service = GuideProposalService( session, ProposalAuthority( @@ -455,6 +454,7 @@ async def test_capability_close_failure_rolls_back_every_decision_effect(clean_p grant, close_error=True, ), + external_checker_registry_reader(session), ) if action == "approve": await service.approve( @@ -465,9 +465,7 @@ async def test_capability_close_failure_rolls_back_every_decision_effect(clean_p actor=actor, request_id=uuid4(), material=guide_document_manifest_port(session), - pre_capabilities=project_guide_pre_submission_capabilities(planner), - post_capabilities=current_post_submit_catalogue(), - planner=planner, + capabilities=await external_checker_capability_projection(session), ) else: await service.request_correction( @@ -595,7 +593,6 @@ async def test_unified_approval_postgresql_requires_reservation_and_operation( async with proposal_case(clean_postgres_database) as (_, factory, command, actor, grant): package = await read_package(factory, command, actor, grant) - planner = build_pre_submission_checker_catalogue() if omitted == "reservation": async def reserve_without_record(*_args, **_kwargs): @@ -627,6 +624,11 @@ def add_without_operation(row, **kwargs): add(row, **kwargs) monkeypatch.setattr(session, "add", add_without_operation) + from app.adapters.checkers import ( + external_checker_capability_projection, + external_checker_registry_reader, + ) + await GuideProposalService( session, ProposalAuthority( @@ -635,6 +637,7 @@ def add_without_operation(row, **kwargs): command.project_id, grant, ), + external_checker_registry_reader(session), ).approve( GuideProposalApproval( target=package.target, @@ -643,9 +646,7 @@ def add_without_operation(row, **kwargs): actor=actor, request_id=uuid4(), material=guide_document_manifest_port(session), - pre_capabilities=project_guide_pre_submission_capabilities(planner), - post_capabilities=current_post_submit_catalogue(), - planner=planner, + capabilities=await external_checker_capability_projection(session), ) # Reach this exact deferred guard with complete output pointers; # other constraints cannot mask the missing relation under test. @@ -848,7 +849,11 @@ async def test_projected_draft_content_cannot_be_changed_before_approval(clean_p @pytest.mark.parametrize("guide_version", ["v0.1", "example-proof"]) async def test_approval_preserves_opaque_guide_version(clean_postgres_database, guide_version): async with proposal_case(clean_postgres_database, guide_version=guide_version) as ( - values, factory, command, actor, grant + values, + factory, + command, + actor, + grant, ): package = await read_package(factory, command, actor, grant) payload = GuideProposalApproval(target=package.target, idempotency_key=uuid4()) @@ -856,13 +861,28 @@ async def test_approval_preserves_opaque_guide_version(clean_postgres_database, assert await approve(factory, command, actor, grant, payload) == receipt async with factory() as session: operation = await session.get(ProjectGuideProposalApproval, receipt.operation_id) - assert operation.effective_pre_submit_plan["lineage"]["guide_version"] == guide_version + assert operation.effective_pre_submit_plan["schema_version"] == ( + "external_checker_policies.v1" + ) assert operation.target_json["guide_version"] == guide_version await finalize(factory, values, command) -@pytest.mark.parametrize("authority_change", ["revoked_grant", "suspended_actor", "revoked_link", "audit", "operator", "system_manager", "foreign_project"]) -async def test_stored_authority_variants_deny_review_replay_and_correction(clean_postgres_database, authority_change): +@pytest.mark.parametrize( + "authority_change", + [ + "revoked_grant", + "suspended_actor", + "revoked_link", + "audit", + "operator", + "system_manager", + "foreign_project", + ], +) +async def test_stored_authority_variants_deny_review_replay_and_correction( + clean_postgres_database, authority_change +): from sqlalchemy import text from .pg_support import seed_review_actor, revoke_review_grant from project_create_fixtures import seed_historical_project @@ -878,44 +898,95 @@ async def test_stored_authority_variants_deny_review_replay_and_correction(clean elif authority_change in {"suspended_actor", "revoked_link"}: async with factory() as session, session.begin(): if authority_change == "suspended_actor": - await session.execute(text("UPDATE actor_profiles SET status='suspended',suspended_by=:id,suspended_at=now(),suspension_reason='Test turnover' WHERE id=:id"), {"id": str(actor.actor_profile_id)}) + await session.execute( + text( + "UPDATE actor_profiles SET status='suspended',suspended_by=:id,suspended_at=now(),suspension_reason='Test turnover' WHERE id=:id" + ), + {"id": str(actor.actor_profile_id)}, + ) else: - await session.execute(text("UPDATE actor_identity_links SET status='revoked',revoked_by=:actor,revoked_at=now(),revoked_reason='Test turnover' WHERE id=:id"), {"actor": str(actor.actor_profile_id), "id": str(actor.identity_link_id)}) + await session.execute( + text( + "UPDATE actor_identity_links SET status='revoked',revoked_by=:actor,revoked_at=now(),revoked_reason='Test turnover' WHERE id=:id" + ), + {"actor": str(actor.actor_profile_id), "id": str(actor.identity_link_id)}, + ) else: project = command.project_id - role, scope = {"audit": ("audit_authority", "project"), "operator": ("operator", "system"), - "system_manager": ("project_manager", "system"), "foreign_project": ("project_manager", "project")}[authority_change] + role, scope = { + "audit": ("audit_authority", "project"), + "operator": ("operator", "system"), + "system_manager": ("project_manager", "system"), + "foreign_project": ("project_manager", "project"), + }[authority_change] if authority_change == "foreign_project": project = new_record_id() async with factory() as session, session.begin(): - await seed_historical_project(session, project_id=str(project), name="Foreign guide owner", slug=f"foreign-{project}") - _, denied_grant = await seed_review_actor(factory, project, actor=actor, role=role, scope=scope) + await seed_historical_project( + session, + project_id=str(project), + name="Foreign guide owner", + slug=f"foreign-{project}", + ) + _, denied_grant = await seed_review_actor( + factory, project, actor=actor, role=role, scope=scope + ) before = await stored_state(factory, command) async with factory() as session: - audit_before = (await session.execute(text("SELECT id FROM audit_events ORDER BY id"))).all() + audit_before = ( + await session.execute(text("SELECT id FROM audit_events ORDER BY id")) + ).all() for operation in ( read_package(factory, command, actor, denied_grant), approve(factory, command, actor, denied_grant, payload), - correct(factory, command, actor, denied_grant, GuideProposalCorrection( - target=package.target, idempotency_key=uuid4(), reason="Reconsider this proposal.")), + correct( + factory, + command, + actor, + denied_grant, + GuideProposalCorrection( + target=package.target, + idempotency_key=uuid4(), + reason="Reconsider this proposal.", + ), + ), ): with pytest.raises(GuideProposalError, match="authority_unavailable"): await operation assert await stored_state(factory, command) == before async with factory() as session: - assert (await session.execute(text("SELECT id FROM audit_events ORDER BY id"))).all() == audit_before - assert (await session.execute(text("SELECT count(*) FROM project_guide_proposal_corrections"))).scalar_one() == 0 + assert ( + await session.execute(text("SELECT id FROM audit_events ORDER BY id")) + ).all() == audit_before + assert ( + await session.execute( + text("SELECT count(*) FROM project_guide_proposal_corrections") + ) + ).scalar_one() == 0 -async def test_another_current_manager_can_admit_correction_after_creator_revocation(clean_postgres_database): +async def test_another_current_manager_can_admit_correction_after_creator_revocation( + clean_postgres_database, +): from .pg_support import seed_review_actor, revoke_review_grant, request_corrected_attempt - from app.modules.projects.guide_compilation.models import ProjectGuideCompilationRequestOperation + from app.modules.projects.guide_compilation.models import ( + ProjectGuideCompilationRequestOperation, + ) async with proposal_case(clean_postgres_database) as (_, factory, command, actor, grant): package = await read_package(factory, command, actor, grant) before = await stored_state(factory, command) - successor = await correct(factory, command, actor, grant, GuideProposalCorrection( - target=package.target, idempotency_key=uuid4(), reason="Recheck the guide requirement.")) + successor = await correct( + factory, + command, + actor, + grant, + GuideProposalCorrection( + target=package.target, + idempotency_key=uuid4(), + reason="Recheck the guide requirement.", + ), + ) second_actor, _ = await seed_review_actor(factory, command.project_id) await revoke_review_grant(factory, actor, grant) requested = await request_corrected_attempt(factory, second_actor, successor) @@ -923,8 +994,13 @@ async def test_another_current_manager_can_admit_correction_after_creator_revoca async with factory() as session: correction = await session.get(ProjectGuideProposalCorrection, successor.operation_id) setup = await session.get(ProjectSetupRun, str(successor.successor_setup_run_id)) - operation = (await session.scalars(select(ProjectGuideCompilationRequestOperation).where( - ProjectGuideCompilationRequestOperation.attempt_id == requested.attempt_id))).one() + operation = ( + await session.scalars( + select(ProjectGuideCompilationRequestOperation).where( + ProjectGuideCompilationRequestOperation.attempt_id == requested.attempt_id + ) + ) + ).one() assert correction.actor_profile_id == str(actor.actor_profile_id) assert setup.authorized_by_actor_profile_id == str(actor.actor_profile_id) assert operation.actor_profile_id == str(second_actor.actor_profile_id) @@ -939,20 +1015,27 @@ async def assert_approval_replay_unchanged(factory, command, actor, grant, paylo async def counts(): async with factory() as session: - return (await session.execute(text( - "SELECT (SELECT count(*) FROM project_guide_proposal_approvals)," - "(SELECT count(*) FROM submission_policy_mutation_idempotency_records)," - "(SELECT count(*) FROM effective_project_submission_artifact_policies)," - "(SELECT count(*) FROM pre_submit_checker_policies)," - "(SELECT count(*) FROM audit_events)" - ))).one() + return ( + await session.execute( + text( + "SELECT (SELECT count(*) FROM project_guide_proposal_approvals)," + "(SELECT count(*) FROM submission_policy_mutation_idempotency_records)," + "(SELECT count(*) FROM effective_project_submission_artifact_policies)," + "(SELECT count(*) FROM pre_submit_checker_policies)," + "(SELECT count(*) FROM audit_events)" + ) + ) + ).one() + before = await counts() assert await approve(factory, command, actor, grant, payload) == receipt assert await counts() == before @pytest.mark.parametrize("activate_before_commit", [False, True]) -async def test_database_approval_requires_guide_to_remain_draft(clean_postgres_database, activate_before_commit): +async def test_database_approval_requires_guide_to_remain_draft( + clean_postgres_database, activate_before_commit +): from contextlib import nullcontext from sqlalchemy import text from sqlalchemy.exc import DBAPIError @@ -963,30 +1046,72 @@ async def test_database_approval_requires_guide_to_remain_draft(clean_postgres_d await seed_selected_review_revision_inputs(factory, command, actor) package = await read_package(factory, command, actor, grant) before = await stored_state(factory, command) - expected = pytest.raises(DBAPIError, match="proposal approval target is no longer current") if activate_before_commit else nullcontext() + expected = ( + pytest.raises(DBAPIError, match="proposal approval target is no longer current") + if activate_before_commit + else nullcontext() + ) with expected: async with factory() as session, session.begin(): + from app.adapters.checkers import ( + external_checker_capability_projection, + external_checker_registry_reader, + ) + if activate_before_commit: # Transaction rollback restores these unrelated activation prerequisites. - for trigger in ("guide_mutation_product_custody", "guide_lineage_lifecycle_guard"): - await session.execute(text(f"ALTER TABLE project_guides DISABLE TRIGGER {trigger}")) - planner = build_pre_submission_checker_catalogue() - await GuideProposalService(session, ProposalAuthority(session, actor, command.project_id, grant)).approve( - GuideProposalApproval(target=package.target, idempotency_key=uuid4()), actor=actor, request_id=uuid4(), + for trigger in ( + "guide_mutation_product_custody", + "guide_lineage_lifecycle_guard", + ): + await session.execute( + text(f"ALTER TABLE project_guides DISABLE TRIGGER {trigger}") + ) + await GuideProposalService( + session, + ProposalAuthority(session, actor, command.project_id, grant), + external_checker_registry_reader(session), + ).approve( + GuideProposalApproval(target=package.target, idempotency_key=uuid4()), + actor=actor, + request_id=uuid4(), material=guide_document_manifest_port(session), - pre_capabilities=project_guide_pre_submission_capabilities(planner), - post_capabilities=current_post_submit_catalogue(), planner=planner, + capabilities=await external_checker_capability_projection(session), ) if activate_before_commit: # Stage lifecycle drift after valid approval assembly to reach the DB guard itself. - await session.execute(text("UPDATE project_guides SET status='active',approved_by=:actor,effective_at=now() WHERE id=:guide"), - {"actor": str(actor.actor_profile_id), "guide": str(command.guide_id)}) - await session.execute(text("SET CONSTRAINTS guide_proposal_approval_custody IMMEDIATE")) + await session.execute( + text( + "UPDATE project_guides SET status='active',approved_by=:actor,effective_at=now() WHERE id=:guide" + ), + {"actor": str(actor.actor_profile_id), "guide": str(command.guide_id)}, + ) + await session.execute( + text("SET CONSTRAINTS guide_proposal_approval_custody IMMEDIATE") + ) if activate_before_commit: assert await stored_state(factory, command) == before async with factory() as session: - assert await session.scalar(text("SELECT count(*) FROM pg_trigger WHERE tgrelid='project_guides'::regclass AND tgenabled='D'")) == 0 - for table in ("project_guide_proposal_approvals", "submission_policy_mutation_idempotency_records", - "effective_project_submission_artifact_policies", "pre_submit_checker_policies"): + assert ( + await session.scalar( + text( + "SELECT count(*) FROM pg_trigger WHERE tgrelid='project_guides'::regclass AND tgenabled='D'" + ) + ) + == 0 + ) + for table in ( + "project_guide_proposal_approvals", + "submission_policy_mutation_idempotency_records", + "effective_project_submission_artifact_policies", + "pre_submit_checker_policies", + ): assert await session.scalar(text(f"SELECT count(*) FROM {table}")) == 0 - assert await session.scalar(text("SELECT count(*) FROM audit_events WHERE action_id='project.submission_artifact_policy.approve'")) == 0 + assert ( + await session.scalar( + text( + "SELECT count(*) FROM audit_events WHERE action_id='project.submission_artifact_policy.approve'" + ) + ) + == 0 + ) diff --git a/backend/tests/projects/guide_compilation/proposals/test_selected_checks.py b/backend/tests/projects/guide_compilation/proposals/test_selected_checks.py deleted file mode 100644 index 89de3cf35..000000000 --- a/backend/tests/projects/guide_compilation/proposals/test_selected_checks.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Selected guide requirements must survive real policy compilation at approval.""" - -from types import SimpleNamespace -from uuid import uuid4 - -import pytest -from sqlalchemy import select, text - -from app.interfaces.project_agents import ProjectGuideCompilationResult -from app.core.identifiers import new_record_id -from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue -from app.modules.projects.api.guide_proposals import GuideProposalApproval, GuideProposalError -from app.modules.projects.guide_compilation.models import ProjectGuideProposalApproval -from app.modules.projects.guide_compilation.projection_payloads import policy_body -from app.modules.projects.guide_compilation.proposal_approval import _compile_approval -from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs -from tests.projects.guide_compilation.helpers import context, ids, result -from .pg_support import proposal_case, read_package -from .test_postgresql import approve - - -CASES = [ - ("required_evidence", "policy.evidence.minimum", "checker_log", "evidence_paths", "evidence/required-evidence-001"), - ("required_artifacts", "policy.file.require", "answer.md", "artifact_paths", "answer.md"), -] - - -def selected_result(field, capability, value, configured): - body = result().model_dump(mode="json") - body["submission_artifact_policy"][field] = [value] if configured else [] - body["requirements"] = [dict( - requirement_id="required-check", statement="Require the declared submission input.", - disposition="supported_pre_submit", evidence_refs=body["findings"][0]["evidence_refs"], - )] - body["pre_submit_bindings"] = [dict( - requirement_id="required-check", capability_id=capability, - capability_version="v1", stage="pre_submit", - )] - return ProjectGuideCompilationResult.model_validate(body) - - -@pytest.mark.parametrize("field,capability,value,config_key,expected", CASES) -@pytest.mark.parametrize("configured", [False, True]) -async def test_approval_reconciles_selection_with_real_compiled_configuration( - monkeypatch, field, capability, value, config_key, expected, configured, -): - compilation_context = context(ids()) - outcome = selected_result(field, capability, value, configured) - - async def loaded_context(self, session, setup): - return compilation_context - - monkeypatch.setattr(CompilationRequestInputs, "context_for_setup", loaded_context) - material = compilation_context.material - target = SimpleNamespace( - project_id=material.project_id, guide_id=material.guide_id, - guide_version=material.guide_version, source_snapshot_id=material.source_snapshot_id, - source_snapshot_hash=material.source_snapshot_hash, artifact_policy_id=new_record_id(), - digest="sha256:" + "a" * 64, - pre_catalogue_manifest_hash=compilation_context.pre_submission_capabilities.manifest_sha256, - ) - locked = SimpleNamespace(result=outcome, view=SimpleNamespace( - attempt=SimpleNamespace(runtime_configuration=compilation_context.runtime_configuration.model_dump(mode="json")), - setup=object(), policy=SimpleNamespace(policy_body=policy_body(None, outcome.submission_artifact_policy)), - )) - command = SimpleNamespace(target=target, acknowledged_warning_hashes=()) - args = (None, locked, command, new_record_id(), None, - compilation_context.pre_submission_capabilities, - compilation_context.post_submission_capabilities, - build_pre_submission_checker_catalogue()) - if not configured: - with pytest.raises(GuideProposalError) as error: - await _compile_approval(*args) - assert error.value.code == "approval_blocked" - else: - outputs = await _compile_approval(*args) - entries = [entry for entry in outputs.plan.entries if entry.definition_id == capability] - assert len(entries) == 1 - assert entries[0].definition_version == "v1" - assert entries[0].checker_definition_state == "enabled" - assert entries[0].configuration.as_dict()[config_key] == [expected] - assert any(entry.definition_id == "policy.hash.verify" for entry in outputs.plan.entries) - - -@pytest.mark.parametrize("field,capability,value,config_key,expected", CASES) -@pytest.mark.parametrize("configured", [False, True]) -async def test_selected_requirement_is_persisted_or_approval_rejects_without_writes( - clean_postgres_database, field, capability, value, config_key, expected, configured, -): - outcome = selected_result(field, capability, value, configured) - async with proposal_case(clean_postgres_database, outcome=outcome) as (_, factory, command, actor, grant): - package = await read_package(factory, command, actor, grant) - payload = GuideProposalApproval(target=package.target, idempotency_key=uuid4()) - async with factory() as session: - before_audit = await session.scalar(text("SELECT count(*) FROM audit_events")) - if not configured: - with pytest.raises(GuideProposalError) as error: - await approve(factory, command, actor, grant, payload) - assert error.value.code == "approval_blocked" - async with factory() as session: - for table in ("project_guide_proposal_approvals", "effective_project_submission_artifact_policies", - "pre_submit_checker_policies", "submission_policy_mutation_idempotency_records"): - assert await session.scalar(text(f"SELECT count(*) FROM {table}")) == 0 - assert await session.scalar(text("SELECT count(*) FROM audit_events")) == before_audit - assert (await read_package(factory, command, actor, grant)).artifact_policy_status == "draft" - else: - receipt = await approve(factory, command, actor, grant, payload) - async with factory() as session: - operation = await session.scalar(select(ProjectGuideProposalApproval).where( - ProjectGuideProposalApproval.operation_id == receipt.operation_id)) - entries = [entry for entry in operation.effective_pre_submit_plan["entries"] - if entry["definition_id"] == capability] - assert len(entries) == 1 - assert entries[0]["configuration"][config_key] == [expected] - assert operation.receipt_json == receipt.model_dump(mode="json") - assert await approve(factory, command, actor, grant, payload) == receipt diff --git a/backend/tests/projects/guide_compilation/test_automatic_request.py b/backend/tests/projects/guide_compilation/test_automatic_request.py index c80eb4f14..a5c0f4175 100644 --- a/backend/tests/projects/guide_compilation/test_automatic_request.py +++ b/backend/tests/projects/guide_compilation/test_automatic_request.py @@ -14,11 +14,9 @@ guide_document_manifest_port, ) from app.modules.authorization.api import ActorIdentityFacts, ActorKind, AuthorizationDenied -from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue -from app.modules.checkers.catalogue import project_guide_pre_submission_capabilities from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs from app.modules.projects.models import ProjectSetupRun -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue +from tests.checkers.external.support import capability_projection from tests.projects.client_fixtures import ( project_client as project_client, @@ -78,8 +76,7 @@ def acknowledge_automatic_setup(setup): def automatic_service(session, actor): inputs = CompilationRequestInputs( guide_document_manifest_port(session), - project_guide_pre_submission_capabilities(build_pre_submission_checker_catalogue()), - current_post_submit_catalogue(), + capability_projection(), runtime_configuration=runtime_configuration(), ) return _execution_service(session, actor, request_inputs=inputs) @@ -93,7 +90,9 @@ async def test_automatic_source_requires_documents_then_persists_one_request_and from app.modules.projects.guide_compilation.repository import GuideCompilationIntegrityError async with factory() as session: - with pytest.raises(GuideCompilationIntegrityError, match="automatic compilation setup unavailable"): + with pytest.raises( + GuideCompilationIntegrityError, match="automatic compilation setup unavailable" + ): await automatic_service(session, actor).request_automatic( actor=actor, setup_run_id=setup_id ) @@ -252,9 +251,9 @@ async def test_sql_origin_rejects_each_missing_lineage_selector(automatic_source factory, actor, setup_id, snapshot = automatic_source await create_committed_document_fixture(snapshot["id"]) async with factory() as session, session.begin(): - facts, _identity, origin = await automatic_service( - session, actor - )._request_inputs.resolve(session, setup_id) + facts, _identity, origin = await automatic_service(session, actor)._request_inputs.resolve( + session, setup_id + ) async with factory() as session: changed = replace( facts, **{field: facts.setup_generation + 1 if field == "setup_generation" else uuid4()} @@ -482,7 +481,9 @@ async def revoke_between_reservation_rollback_and_recovery(service, **kwargs): ], ) async def test_stored_foreign_source_is_rejected_by_repository_and_insert( - automatic_source, project_client, field # noqa: F811 + automatic_source, + project_client, + field, # noqa: F811 ): from dataclasses import replace from sqlalchemy.exc import IntegrityError @@ -557,9 +558,7 @@ async def test_stored_foreign_source_is_rejected_by_repository_and_insert( if field in origin.__dataclass_fields__ else getattr(changed_facts, field) ) - stored_facts = replace( - changed_facts, operation_id=UUID(str(values["operation_id"])) - ) + stored_facts = replace(changed_facts, operation_id=UUID(str(values["operation_id"]))) forged = { **values, field: replacement if field.endswith("operation_id") else str(replacement), @@ -642,8 +641,6 @@ async def test_direct_insert_checks_exact_authority_digest(automatic_source, eve ) - - @pytest.mark.asyncio @pytest.mark.parametrize("authority", ["profile", "identity_link"]) async def test_automatic_replay_holds_authority_until_receipt_classification( diff --git a/backend/tests/projects/guide_compilation/test_capability_growth.py b/backend/tests/projects/guide_compilation/test_capability_growth.py index bab7c820d..9a6c230a5 100644 --- a/backend/tests/projects/guide_compilation/test_capability_growth.py +++ b/backend/tests/projects/guide_compilation/test_capability_growth.py @@ -1,77 +1,132 @@ """Catalogue growth reports retain exact matches without inventing executable checks.""" + from uuid import uuid4 import pytest from pydantic import ValidationError from app.interfaces.project_agents import ( - AtomicGuideRequirement, CapabilitySuggestion, CompilationFinding, - PostSubmissionBindingProposal, PreSubmissionBindingProposal, - ProjectGuideCompilationResult, validate_project_guide_compilation_result, + AtomicGuideRequirement, + CapabilitySuggestion, + CompilationFinding, + PostSubmissionBindingProposal, + PreSubmissionBindingProposal, + ProjectGuideCompilationResult, + validate_project_guide_compilation_result, ) from .helpers import context, ids, result +from tests.checkers.external.support import capability_projection, registry_entry + + +def _context(): + """Expose one exact selectable image in each phase for growth validation.""" + return context( + ids(), + checker_capabilities=capability_projection( + registry_entry("pre_submit"), registry_entry("post_submit") + ), + ) def growth_report(*, gaps=True): """Use current selectable definitions and distinct missing checks in both stages.""" - source = context(ids()) + source = _context() refs = result().findings[0].evidence_refs requirements, suggestions, pre, post = [], [], [], [] for stage in ("pre_submit", "post_submit"): key = f"supported_{stage}" - requirements.append(AtomicGuideRequirement( - requirement_id=key, statement="Require the registered structural check.", - disposition=f"supported_{stage}", evidence_refs=refs, - )) + requirements.append( + AtomicGuideRequirement( + requirement_id=key, + statement="Require the registered structural check.", + disposition=f"supported_{stage}", + evidence_refs=refs, + ) + ) if stage == "pre_submit": - definition = next(d for d in source.pre_submission_capabilities.definitions if d.selectable) - pre.append(PreSubmissionBindingProposal( - requirement_id=key, capability_id=definition.stable_id, - capability_version=definition.version, stage=stage, - )) + definition = next( + d + for d in source.checker_capabilities.definitions + if d.selectable and d.phase == stage + ) + pre.append( + PreSubmissionBindingProposal( + requirement_id=key, + capability_id=definition.capability_id, + capability_version=definition.capability_version, + stage=stage, + ) + ) else: - definition = next(d for d in source.post_submission_capabilities.definitions if d.selectable) - post.append(PostSubmissionBindingProposal( - requirement_id=key, capability_id=definition.capability_id, - capability_version=definition.capability_version, stage=stage, - )) + definition = next( + d + for d in source.checker_capabilities.definitions + if d.selectable and d.phase == stage + ) + post.append( + PostSubmissionBindingProposal( + requirement_id=key, + capability_id=definition.capability_id, + capability_version=definition.capability_version, + stage=stage, + ) + ) if gaps: key = f"gap_{stage}" - requirements.append(AtomicGuideRequirement( - requirement_id=key, statement="Require an unavailable automated semantic check.", - disposition=f"{stage}_capability_gap", evidence_refs=refs, - )) - suggestions.append(CapabilitySuggestion( - requirement_id=key, stage=stage, title="Automated semantic verification", - rationale="Engineering must implement and register the required semantic check.", - evidence_refs=refs, - )) - return result().model_copy(update={ - "status": "guide_blocked" if gaps else "draft_ready", - "submission_artifact_policy": None if gaps else result().submission_artifact_policy, - "findings": (CompilationFinding( - severity="blocking_gap" if gaps else "info", code="catalogue.coverage", - message="Required automation is unavailable." if gaps else "All checks are supported.", - evidence_refs=refs, - ),), - "requirements": tuple(requirements), "capability_suggestions": tuple(suggestions), - "pre_submit_bindings": tuple(pre), "post_submit_bindings": tuple(post), - }) + requirements.append( + AtomicGuideRequirement( + requirement_id=key, + statement="Require an unavailable automated semantic check.", + disposition=f"{stage}_capability_gap", + evidence_refs=refs, + ) + ) + suggestions.append( + CapabilitySuggestion( + requirement_id=key, + stage=stage, + title="Automated semantic verification", + rationale="Engineering must implement and register the required semantic check.", + evidence_refs=refs, + ) + ) + return result().model_copy( + update={ + "status": "draft_ready_with_warnings" if gaps else "draft_ready", + "submission_artifact_policy": result().submission_artifact_policy, + "findings": ( + CompilationFinding( + severity="warning" if gaps else "info", + code="external_checker.coverage", + message="Required automation is unavailable." + if gaps + else "All checks are supported.", + evidence_refs=refs, + ), + ), + "requirements": tuple(requirements), + "capability_suggestions": tuple(suggestions), + "pre_submit_bindings": tuple(pre), + "post_submit_bindings": tuple(post), + } + ) @pytest.mark.parametrize("gaps", [True, False]) def test_exact_matches_and_gap_handoff(gaps): report = growth_report(gaps=gaps) parsed = ProjectGuideCompilationResult.model_validate_json(report.model_dump_json()) - validate_project_guide_compilation_result(context(ids()), parsed) + validate_project_guide_compilation_result(_context(), parsed) assert len(parsed.capability_suggestions) == (2 if gaps else 0) assert len(parsed.pre_submit_bindings) == len(parsed.post_submit_bindings) == 1 -@pytest.mark.parametrize("fault", ["missing", "duplicate", "orphan", "wrong_stage", "non_gap", "foreign_evidence"]) +@pytest.mark.parametrize( + "fault", ["missing", "duplicate", "orphan", "wrong_stage", "non_gap", "foreign_evidence"] +) def test_gap_handoff_rejects_invalid_linkage(fault): report = growth_report() - validate_project_guide_compilation_result(context(ids()), report) + validate_project_guide_compilation_result(_context(), report) suggestions = list(report.capability_suggestions) if fault == "missing": suggestions.pop() @@ -82,17 +137,28 @@ def test_gap_handoff_rejects_invalid_linkage(fault): "orphan": {"requirement_id": "absent"}, "wrong_stage": {"stage": "post_submit"}, "non_gap": {"requirement_id": "supported_pre_submit"}, - "foreign_evidence": {"evidence_refs": (suggestions[0].evidence_refs[0].model_copy( - update={"document_version_id": uuid4()}),)}, + "foreign_evidence": { + "evidence_refs": ( + suggestions[0] + .evidence_refs[0] + .model_copy(update={"document_version_id": uuid4()}), + ) + }, }[fault] suggestions[0] = CapabilitySuggestion.model_validate( suggestions[0].model_dump(mode="python") | update ) - report = ProjectGuideCompilationResult.model_validate(report.model_copy(update={ - "capability_suggestions": tuple(suggestions), - }).model_dump(mode="json")) - with pytest.raises(ValueError, match="source lineage" if fault == "foreign_evidence" else "suggestion"): - validate_project_guide_compilation_result(context(ids()), report) + report = ProjectGuideCompilationResult.model_validate( + report.model_copy( + update={ + "capability_suggestions": tuple(suggestions), + } + ).model_dump(mode="json") + ) + with pytest.raises( + ValueError, match="source lineage" if fault == "foreign_evidence" else "suggestion" + ): + validate_project_guide_compilation_result(_context(), report) @pytest.mark.parametrize("field", ["requirement_id", "stage", "evidence_refs"]) @@ -113,83 +179,143 @@ def test_suggestion_rejects_empty_evidence(): @pytest.mark.parametrize("disposition", ["human_review", "informational"]) def test_non_automated_requirements_need_no_suggestion(disposition): report = growth_report(gaps=False) - extra = AtomicGuideRequirement(requirement_id="human", statement="A reviewer assesses quality.", - disposition=disposition, evidence_refs=report.findings[0].evidence_refs) - report = report.model_copy(update={"requirements": (*report.requirements, extra), - "setup_notes": ("Consider an optional future reporting improvement.",)}) - validate_project_guide_compilation_result(context(ids()), report) + extra = AtomicGuideRequirement( + requirement_id="human", + statement="A reviewer assesses quality.", + disposition=disposition, + evidence_refs=report.findings[0].evidence_refs, + ) + report = report.model_copy( + update={ + "requirements": (*report.requirements, extra), + "setup_notes": ("Consider an optional future reporting improvement.",), + } + ) + validate_project_guide_compilation_result(_context(), report) assert report.capability_suggestions == () @pytest.mark.parametrize("stage", ["pre_submit", "post_submit"]) -@pytest.mark.parametrize("fault", ["missing", "duplicate", "unknown", "stale", "stage", "configuration"]) -def test_blocked_matches_still_require_exact_catalogue_validation(stage, fault): +@pytest.mark.parametrize( + "fault", ["missing", "duplicate", "unknown", "stale", "stage", "configuration"] +) +def test_blocked_matches_still_require_exact_external_capability_validation(stage, fault): report = growth_report() field = f"{stage}_bindings" binding = getattr(report, field)[0] - if fault == "configuration" and stage == "pre_submit": - payload = binding.model_dump(mode="json") | {"parameters": [{"name": "invented", "value": 1}]} - with pytest.raises(ValidationError, match="parameters"): - PreSubmissionBindingProposal.model_validate(payload) - return - updates = {"unknown": {"capability_id": "invented"}, "stale": {"capability_version": "absent"}, - "stage": {"stage": "post_submit" if stage == "pre_submit" else "pre_submit"}, - "configuration": {"parameters": [{"name": "invented", "value": 1}]}} + updates = { + "unknown": {"capability_id": "invented"}, + "stale": {"capability_version": "absent"}, + "stage": {"stage": "post_submit" if stage == "pre_submit" else "pre_submit"}, + "configuration": {"parameters": [{"name": "invented", "value": 1}]}, + } if fault in updates: binding = type(binding).model_validate(binding.model_dump(mode="json") | updates[fault]) - bindings = () if fault == "missing" else ((binding, binding) if fault == "duplicate" else (binding,)) + bindings = ( + () if fault == "missing" else ((binding, binding) if fault == "duplicate" else (binding,)) + ) with pytest.raises(ValueError): - validate_project_guide_compilation_result(context(ids()), report.model_copy(update={field: bindings})) + validate_project_guide_compilation_result( + _context(), report.model_copy(update={field: bindings}) + ) @pytest.mark.parametrize("count", [50, 51, 100, 101, 200, 201]) @pytest.mark.parametrize("kind", ["gap", "pre_submit", "post_submit"]) def test_requirement_and_handoff_limits_align(count, kind): base = growth_report(gaps=kind == "gap") - requirement = next(r for r in base.requirements if r.requirement_id == - ("gap_pre_submit" if kind == "gap" else f"supported_{kind}")) + requirement = next( + r + for r in base.requirements + if r.requirement_id == ("gap_pre_submit" if kind == "gap" else f"supported_{kind}") + ) field = "capability_suggestions" if kind == "gap" else f"{kind}_bindings" item = getattr(base, field)[0] payload = base.model_dump(mode="json") | { - "requirements": [requirement.model_dump(mode="json") | {"requirement_id": f"r{i}"} for i in range(count)], - "capability_suggestions": [], "pre_submit_bindings": [], "post_submit_bindings": [], + "requirements": [ + requirement.model_dump(mode="json") | {"requirement_id": f"r{i}"} for i in range(count) + ], + "capability_suggestions": [], + "pre_submit_bindings": [], + "post_submit_bindings": [], } - payload[field] = [item.model_dump(mode="json") | {"requirement_id": f"r{i}"} for i in range(count)] + payload[field] = [ + item.model_dump(mode="json") | {"requirement_id": f"r{i}"} for i in range(count) + ] if count > 200: with pytest.raises(ValidationError, match="at most 200"): ProjectGuideCompilationResult.model_validate(payload) else: parsed = ProjectGuideCompilationResult.model_validate(payload) - validate_project_guide_compilation_result(context(ids()), parsed) + validate_project_guide_compilation_result(_context(), parsed) @pytest.mark.parametrize("stage", ["pre_submit", "post_submit"]) def test_blocked_report_rejects_platform_defaults_as_project_bindings(stage): - source = context(ids()) + source = _context() report = growth_report() - definitions = (source.pre_submission_capabilities.definitions if stage == "pre_submit" - else source.post_submission_capabilities.definitions) + definitions = ( + source.checker_capabilities.definitions + if stage == "pre_submit" + else source.checker_capabilities.definitions + ) definition = next(d for d in definitions if not d.selectable) binding = getattr(report, f"{stage}_bindings")[0] - binding = binding.model_copy(update={ - "capability_id": definition.stable_id if stage == "pre_submit" else definition.capability_id, - "capability_version": definition.version if stage == "pre_submit" else definition.capability_version, - }) + binding = binding.model_copy( + update={ + "capability_id": definition.capability_id + if stage == "pre_submit" + else definition.capability_id, + "capability_version": definition.capability_version + if stage == "pre_submit" + else definition.capability_version, + } + ) with pytest.raises(ValueError, match="compilation capability binding is invalid"): - validate_project_guide_compilation_result(source, report.model_copy(update={f"{stage}_bindings": (binding,)})) + validate_project_guide_compilation_result( + source, report.model_copy(update={f"{stage}_bindings": (binding,)}) + ) def test_blocked_handoff_cannot_include_artifact_policy(): - report = growth_report() - validate_project_guide_compilation_result(context(ids()), report) + report = result().model_copy( + update={ + "status": "guide_blocked", + "submission_artifact_policy": None, + "findings": ( + CompilationFinding( + severity="blocking_gap", + code="guide.invalid", + message="The guide cannot define a valid task policy.", + evidence_refs=result().findings[0].evidence_refs, + ), + ), + "requirements": ( + AtomicGuideRequirement( + requirement_id="guide_invalid", + statement="The guide must define a valid task policy.", + disposition="guide_blocker", + evidence_refs=result().findings[0].evidence_refs, + ), + ), + } + ) + validate_project_guide_compilation_result(_context(), report) with pytest.raises(ValueError, match="blocked guide cannot publish policy proposals"): - validate_project_guide_compilation_result(context(ids()), report.model_copy(update={ - "submission_artifact_policy": result().submission_artifact_policy, - })) + validate_project_guide_compilation_result( + _context(), + report.model_copy( + update={ + "submission_artifact_policy": result().submission_artifact_policy, + } + ), + ) -@pytest.mark.parametrize("field", ["requirements", "capability_suggestions", - "pre_submit_bindings", "post_submit_bindings"]) +@pytest.mark.parametrize( + "field", + ["requirements", "capability_suggestions", "pre_submit_bindings", "post_submit_bindings"], +) def test_each_collection_ceiling_rejects_independently(field): """Schema proof keeps every other collection within bounds, before semantic checks.""" payload = growth_report().model_dump(mode="json") 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 d70a85aae..f2c9f4172 100644 --- a/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py @@ -1,4 +1,4 @@ -"""Persist blocked catalogue handoffs and terminalize oversized output without inference replay.""" +"""Persist external-capability gap handoffs and terminalize oversized output.""" import json import pytest @@ -26,6 +26,13 @@ async def test_handoff_persistence_and_terminal_replay(automatic_source, monkeyp from app.workers import project_setup as worker factory, actor, setup_id, snapshot = automatic_source + from tests.checkers.external.support import registry_spec + from tests.projects.guide_activation.source_fixtures import ( + _register_external_checker_fixture, + ) + + await _register_external_checker_fixture(factory, registry_spec("pre_submit")) + await _register_external_checker_fixture(factory, registry_spec("post_submit")) await create_committed_document_fixture(snapshot["id"]) delivery = await _delivery(factory, setup_id) runtime = ScriptedGuideRuntime() @@ -52,8 +59,10 @@ async def compile_project_guide(self, context, capabilities): runtime.outcome = growth_report() monkeypatch.setattr(worker, "create_project_guide_runtime", lambda configuration: runtime) monkeypatch.setattr(worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration()) - first = await worker._coordinator(factory).run(delivery) - assert first["status"] == ("compilation_invalid_terminal" if oversized else "sufficiency_blocked") + first = await (await worker._coordinator(factory)).run(delivery) + assert first["status"] == ( + "compilation_invalid_terminal" if oversized else "policy_draft_ready" + ) if oversized: assert first["error_code"] == "schema_invalid" assert runtime.calls == 1 @@ -71,22 +80,23 @@ async def compile_project_guide(self, context, capabilities): assert len(parsed.pre_submit_bindings) == len(parsed.post_submit_bindings) == 1 assert {s.stage.value for s in parsed.capability_suggestions} == {"pre_submit", "post_submit"} assert {s.requirement_id for s in parsed.capability_suggestions} == {"gap_pre_submit", "gap_post_submit"} - final = (await session.execute(text( - "select artifact_policy_id, artifact_policy_operation_id, artifact_policy_output_digest " - "from project_guide_setup_finalizations" - ))).one() - assert tuple(final) == (None, None, None) await _assert_projection_state(session, setup_id, oversized) - for table in ("project_guide_compilations", "project_guide_component_projection_operations", - "project_guide_setup_finalizations"): - assert await session.scalar(text(f"select count(*) from {table}")) == (0 if oversized else 1) + expected_counts = { + "project_guide_compilations": 1, + "project_guide_component_projection_operations": 2, + "project_guide_setup_finalizations": 1, + } + for table, expected in expected_counts.items(): + assert await session.scalar(text(f"select count(*) from {table}")) == ( + 0 if oversized else expected + ) def forbidden(*args): pytest.fail("terminal replay constructed a provider runtime") monkeypatch.setattr(worker, "create_project_guide_runtime", forbidden) monkeypatch.setattr(worker, "project_guide_runtime_configuration", forbidden) - assert await worker._coordinator(factory).run(delivery) == first + assert await (await worker._coordinator(factory)).run(delivery) == first assert runtime.calls == 1 async with factory() as session: assert await session.scalar(text("select count(*) from audit_events")) == before @@ -106,23 +116,54 @@ async def test_driver_json_byte_measure_matches_postgresql(automatic_source): async def _assert_projection_state(session, setup_id, oversized): - """Observe both checker stages and the exact sole sufficiency output before/after replay.""" - for table in ("submission_artifact_policies", "effective_project_submission_artifact_policies", - "pre_submit_checker_policies", "checker_policies"): + """Observe the current draft handoff or exact terminal absence before/after replay.""" + policy_ids = ( + await session.execute(text("select id from submission_artifact_policies order by id")) + ).scalars().all() + assert len(policy_ids) == (0 if oversized else 1) + for table in ( + "effective_project_submission_artifact_policies", + "pre_submit_checker_policies", + "checker_policies", + ): assert await session.scalar(text(f"select count(*) from {table}")) == 0 outputs = (await session.execute(text( - "select output_sufficiency_report_id, output_submission_artifact_policy_id, " - "output_post_submit_checker_policy_id from project_setup_runs where id=:id" + "select status,current_step,output_sufficiency_report_id, " + "output_submission_artifact_policy_id,output_post_submit_checker_policy_id " + "from project_setup_runs where id=:id" ), {"id": str(setup_id)})).one() - assert outputs.output_submission_artifact_policy_id is None assert outputs.output_post_submit_checker_policy_id is None reports = (await session.execute(text("select id from guide_sufficiency_reports"))).scalars().all() projections = (await session.execute(text( - "select component,report_id,policy_id from project_guide_component_projection_operations" + "select operation_id,component,report_id,policy_id " + "from project_guide_component_projection_operations order by component" ))).all() if oversized: + assert (outputs.status, outputs.current_step) == ("queued", "queued") assert reports == [] and projections == [] assert outputs.output_sufficiency_report_id is None + assert outputs.output_submission_artifact_policy_id is None else: + assert (outputs.status, outputs.current_step) == ( + "policy_draft_ready", + "submission_artifact_policy_derivation", + ) assert reports == [outputs.output_sufficiency_report_id] - assert projections == [("guide_sufficiency", outputs.output_sufficiency_report_id, None)] + assert policy_ids == [outputs.output_submission_artifact_policy_id] + assert [tuple(row)[1:] for row in projections] == [ + ("guide_sufficiency", outputs.output_sufficiency_report_id, None), + ("submission_artifact_policy", None, outputs.output_submission_artifact_policy_id), + ] + final = (await session.execute(text( + "select sufficiency_report_id,artifact_policy_id,sufficiency_operation_id," + "artifact_policy_operation_id,sufficiency_output_digest," + "artifact_policy_output_digest from project_guide_setup_finalizations" + ))).one() + assert tuple(final[:4]) == ( + outputs.output_sufficiency_report_id, + outputs.output_submission_artifact_policy_id, + projections[0].operation_id, + projections[1].operation_id, + ) + assert final.sufficiency_output_digest.startswith("sha256:") + assert final.artifact_policy_output_digest.startswith("sha256:") diff --git a/backend/tests/projects/guide_compilation/test_compilation_storage_limit.py b/backend/tests/projects/guide_compilation/test_compilation_storage_limit.py index 89a0e7127..6b1ed56b4 100644 --- a/backend/tests/projects/guide_compilation/test_compilation_storage_limit.py +++ b/backend/tests/projects/guide_compilation/test_compilation_storage_limit.py @@ -9,7 +9,7 @@ project_guide_compilation_result_storage_bytes, validate_project_guide_compilation_result, ) from .helpers import context, ids -from .test_capability_growth import growth_report +from .test_capability_growth import _context as growth_context, growth_report def large_report(length): @@ -62,7 +62,7 @@ def test_actual_sdk_parser_accepts_current_handoff(gaps): schema = AgentOutputSchema(ProjectGuideCompilationResult) # Strict SDK parsing sees every field, including explicit linkage and evidence. parsed = schema.validate_json(growth_report(gaps=gaps).model_dump_json()) - validate_project_guide_compilation_result(context(ids()), parsed) + validate_project_guide_compilation_result(growth_context(), parsed) assert len(parsed.capability_suggestions) == (2 if gaps else 0) diff --git a/backend/tests/projects/guide_compilation/test_context_builder.py b/backend/tests/projects/guide_compilation/test_context_builder.py index 5c6c8541d..b4279ab10 100644 --- a/backend/tests/projects/guide_compilation/test_context_builder.py +++ b/backend/tests/projects/guide_compilation/test_context_builder.py @@ -2,8 +2,6 @@ from __future__ import annotations -from app.modules.checkers.catalogue import project_guide_pre_submission_capabilities - from tests.projects.guide_compilation.helpers import runtime_configuration from app.modules.authorization.api import ProjectGuideCompilationRequestOrigin @@ -17,9 +15,6 @@ guide_document_manifest_port, ) from app.modules.authorization.api import ActorIdentityFacts, ActorKind -from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, -) from app.modules.projects.guide_compilation.context import ( build_project_guide_compilation_context, ) @@ -30,7 +25,6 @@ from app.modules.projects.guide_compilation.service import ( load_compilation_execution_state, ) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from .helpers import context, identity, seed_database, DOCUMENT_VERSION_ID, PUT_ATTEMPT_ID from .test_authorized_request_service import _authorized_service, _request, _seed_human @@ -61,10 +55,7 @@ async def test_context_rebuilds_the_exact_authorized_art_backed_identity( session, state=state, material=guide_document_manifest_port(session), - pre_submission_capabilities=project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ), - post_submission_capabilities=current_post_submit_catalogue(), + checker_capabilities=context(values).checker_capabilities, ) assert rebuilt == context(values) @@ -95,20 +86,16 @@ async def test_context_drift_fails_before_dispatch( ) async with factory() as session: state = await load_compilation_execution_state(session, request.attempt_id) - default_catalogue = build_pre_submission_checker_catalogue() - drifted_catalogue = build_pre_submission_checker_catalogue( - disabled_entry_ids={default_catalogue.entries[0].stable_id} - ) + from tests.checkers.external.support import capability_projection, registry_entry + + drifted_capabilities = capability_projection(registry_entry("pre_submit")) async with factory() as session: with pytest.raises(GuideCompilationIntegrityError, match="identity mismatch"): await build_project_guide_compilation_context( session, state=state, material=guide_document_manifest_port(session), - pre_submission_capabilities=project_guide_pre_submission_capabilities( - drifted_catalogue - ), - post_submission_capabilities=(current_post_submit_catalogue()), + checker_capabilities=drifted_capabilities, ) finally: await engine.dispose() @@ -134,18 +121,14 @@ async def test_context_requires_fresh_session_and_current_lineage( ) async with factory() as session: state = await load_compilation_execution_state(session, request.attempt_id) - capabilities = project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ) - post_capabilities = current_post_submit_catalogue() + capabilities = context(values).checker_capabilities async with factory() as session, session.begin(): with pytest.raises(GuideCompilationIntegrityError, match="fresh root"): await build_project_guide_compilation_context( session, state=state, material=guide_document_manifest_port(session), - pre_submission_capabilities=capabilities, - post_submission_capabilities=post_capabilities, + checker_capabilities=capabilities, ) missing = replace( state, @@ -157,8 +140,7 @@ async def test_context_requires_fresh_session_and_current_lineage( session, state=missing, material=guide_document_manifest_port(session), - pre_submission_capabilities=capabilities, - post_submission_capabilities=post_capabilities, + checker_capabilities=capabilities, ) finally: await engine.dispose() @@ -196,16 +178,13 @@ async def test_context_enforces_the_canonical_prompt_limit( session, state=state, material=guide_document_manifest_port(session), - pre_submission_capabilities=project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ), - post_submission_capabilities=(current_post_submit_catalogue()), + checker_capabilities=context(values).checker_capabilities, ) finally: await engine.dispose() -@pytest.mark.parametrize('fault', ['manifest_lineage', 'missing_examples', 'changed_examples']) +@pytest.mark.parametrize("fault", ["manifest_lineage", "missing_examples", "changed_examples"]) def test_context_constructor_rejects_input_drift_after_valid_control(fault): from types import SimpleNamespace from app.modules.projects.guide_compilation.context import compilation_context_from_material @@ -213,31 +192,45 @@ def test_context_constructor_rejects_input_drift_after_valid_control(fault): from .helpers import ids expected = context(ids()) - examples = [item.model_dump(mode='json') for item in expected.task_examples] - guide = SimpleNamespace(id=str(expected.material.guide_id), - project_id=str(expected.material.project_id), version=expected.material.guide_version, - task_examples=examples, task_examples_hash=task_examples_hash(expected.task_examples)) - snapshot = SimpleNamespace(id=str(expected.material.source_snapshot_id), + examples = [item.model_dump(mode="json") for item in expected.task_examples] + guide = SimpleNamespace( + id=str(expected.material.guide_id), + project_id=str(expected.material.project_id), + version=expected.material.guide_version, + task_examples=examples, + task_examples_hash=task_examples_hash(expected.task_examples), + ) + snapshot = SimpleNamespace( + id=str(expected.material.source_snapshot_id), bundle_hash=expected.material.source_snapshot_hash, - manifest_json={'task_examples_hash': guide.task_examples_hash, - 'task_examples_count': len(examples)}) - values = dict(guide=guide, snapshot=snapshot, loaded=expected.material, - setup_run_id=expected.setup_run_id, setup_generation=expected.setup_generation, - pre_submission_capabilities=expected.pre_submission_capabilities, - post_submission_capabilities=expected.post_submission_capabilities, - runtime_configuration=expected.runtime_configuration) + manifest_json={ + "task_examples_hash": guide.task_examples_hash, + "task_examples_count": len(examples), + }, + ) + values = dict( + guide=guide, + snapshot=snapshot, + loaded=expected.material, + setup_run_id=expected.setup_run_id, + setup_generation=expected.setup_generation, + checker_capabilities=expected.checker_capabilities, + runtime_configuration=expected.runtime_configuration, + ) assert compilation_context_from_material(**values) == expected - if fault == 'manifest_lineage': + if fault == "manifest_lineage": snapshot.id = str(uuid4()) - message = 'manifest lineage mismatch' + message = "manifest lineage mismatch" else: - guide.task_examples = None if fault == 'missing_examples' else [{'content': 'Changed task'}] - message = 'task examples are unavailable' + guide.task_examples = None if fault == "missing_examples" else [{"content": "Changed task"}] + message = "task examples are unavailable" with pytest.raises(GuideCompilationIntegrityError, match=message): compilation_context_from_material(**values) -async def test_setup_repository_reads_preserve_scope_without_fabricating_policy(clean_postgres_database): +async def test_setup_repository_reads_preserve_scope_without_fabricating_policy( + clean_postgres_database, +): from app.modules.projects.repository import ProjectRepository values = await seed_database(clean_postgres_database) @@ -246,14 +239,33 @@ async def test_setup_repository_reads_preserve_scope_without_fabricating_policy( try: async with sessions() as session, session.begin(): repository = ProjectRepository(session) - setup = await repository.get_project_setup_run(str(values['setup_1'])) + setup = await repository.get_project_setup_run(str(values["setup_1"])) assert setup is not None - assert await repository.get_latest_project_setup_run(str(values['project']), str(values['guide'])) is setup - assert await repository.get_latest_project_setup_run(str(uuid4()), str(values['guide'])) is None - assert await repository.next_project_setup_generation(str(values['guide'])) == setup.setup_generation + 1 + assert ( + await repository.get_latest_project_setup_run( + str(values["project"]), str(values["guide"]) + ) + is setup + ) + assert ( + await repository.get_latest_project_setup_run(str(uuid4()), str(values["guide"])) + is None + ) + assert ( + await repository.next_project_setup_generation(str(values["guide"])) + == setup.setup_generation + 1 + ) assert await repository.get_project_setup_run(str(uuid4())) is None - assert await repository.get_pre_submit_checker_policy_for_effective_policy(str(uuid4())) is None + assert ( + await repository.get_pre_submit_checker_policy_for_effective_policy(str(uuid4())) + is None + ) assert await repository.lock_compiled_pre_submit_checker_policy(str(uuid4())) is None - assert await repository.lock_post_submit_checker_policy_for_guide(str(values['project']), setup.guide_version) is None + assert ( + await repository.lock_post_submit_checker_policy_for_guide( + str(values["project"]), setup.guide_version + ) + is None + ) finally: await engine.dispose() 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 44ae4471e..56b4f4bdd 100644 --- a/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py @@ -2,8 +2,6 @@ from __future__ import annotations -from app.modules.checkers.catalogue import project_guide_pre_submission_capabilities - from tests.projects.guide_compilation.helpers import runtime_configuration from app.modules.authorization.api import ProjectGuideCompilationRequestOrigin @@ -43,10 +41,7 @@ SqlAlchemyGuideCompilationExecutionBackend, project_guide_compilation_execution_port, ) -from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, -) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue +from tests.checkers.external.support import capability_projection from .helpers import context, identity, result, seed_database from .runtime_fixtures import document_access, record_scripted_document_access @@ -112,23 +107,17 @@ def _backend(factory): factory, material_factory=guide_document_manifest_port, document_access_factory=document_access, - pre_submission_capabilities=project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ), - post_submission_capabilities=current_post_submit_catalogue(), + checker_capabilities=capability_projection(), authorization_context=_fixed_service_authorization, ) -def _port(factory, runtime): +def _port(factory, runtime, *, checker_capabilities=None): return project_guide_compilation_execution_port( factory, material_factory=guide_document_manifest_port, document_access_factory=document_access, - pre_submission_capabilities=project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ), - post_submission_capabilities=current_post_submit_catalogue(), + checker_capabilities=checker_capabilities or capability_projection(), authorization_context=_fixed_service_authorization, runtime_factory=lambda configuration: runtime, ) @@ -552,11 +541,8 @@ async def test_unavailable_authority_returns_only_the_safe_public_code( port = project_guide_compilation_execution_port( factory, material_factory=guide_document_manifest_port, - document_access_factory=document_access, - pre_submission_capabilities=project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ), - post_submission_capabilities=current_post_submit_catalogue(), + document_access_factory=document_access, + checker_capabilities=capability_projection(), authorization_context=_unavailable_service_authorization, runtime_factory=lambda configuration: runtime, ) @@ -573,11 +559,19 @@ async def test_unavailable_authority_returns_only_the_safe_public_code( @pytest.mark.asyncio -@pytest.mark.parametrize("kind, failure_code", [ - ("malformed", "schema_invalid"), ("schema", "schema_invalid"), ("unsafe", "schema_invalid"), -]) +@pytest.mark.parametrize( + "kind, failure_code", + [ + ("malformed", "schema_invalid"), + ("schema", "schema_invalid"), + ("unsafe", "schema_invalid"), + ], +) async def test_sdk_parser_rejection_persists_terminal_without_reinvocation( - clean_postgres_database, monkeypatch, kind, failure_code, + clean_postgres_database, + monkeypatch, + kind, + failure_code, ): """Real SDK rejection persists its exact terminal class and replay makes no call.""" import json @@ -586,6 +580,7 @@ async def test_sdk_parser_rejection_persists_terminal_without_reinvocation( from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime from agents import _debug + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) monkeypatch.setenv("OPENAI_API_KEY", "unit-test-only") payload = result().model_dump(mode="json") @@ -616,7 +611,8 @@ async def close(self): await self.capabilities.documents.close() monkeypatch.setattr( - "app.adapters.project_agents.openai_agent_sdk.OpenAIGuideWorkspace", ScriptedWorkspace, + "app.adapters.project_agents.openai_agent_sdk.OpenAIGuideWorkspace", + ScriptedWorkspace, ) values = await seed_database(clean_postgres_database) requested = await _authorized_attempt(clean_postgres_database, values) @@ -626,15 +622,22 @@ async def close(self): try: port = _port(factory, OpenAIAgentSdkProjectGuideRuntime(runtime_configuration())) first = await port.execute(command) - assert first.classification is ProjectGuideCompilationExecutionClassification.INVALID_TERMINAL + assert ( + first.classification is ProjectGuideCompilationExecutionClassification.INVALID_TERMINAL + ) assert await port.execute(command) == first assert calls == [1] async with factory() as session: - row = (await session.execute(text( - "select status,failure_code,canonical_result, " - "(select count(*) from project_guide_compilations) " - "from project_guide_compilation_attempts where id=:id" - ), {"id": requested.attempt_id})).one() + row = ( + await session.execute( + text( + "select status,failure_code,canonical_result, " + "(select count(*) from project_guide_compilations) " + "from project_guide_compilation_attempts where id=:id" + ), + {"id": requested.attempt_id}, + ) + ).one() assert row == ("compilation_invalid_terminal", failure_code, None, 0) finally: await engine.dispose() 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 531d6d7f1..62c00f18d 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -94,7 +94,7 @@ async def test_pending_exact_delivery_compiles_once_and_replays_finalization( monkeypatch.setattr( worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration() ) - coordinator = worker._coordinator(factory) + coordinator = await worker._coordinator(factory) first = await coordinator.run(delivery) assert first["status"] == ( "sufficiency_blocked" if status == "guide_blocked" else "policy_draft_ready" @@ -141,7 +141,7 @@ def forbidden(*args): monkeypatch.setattr(worker, "create_project_guide_runtime", forbidden) monkeypatch.setattr(worker, "project_guide_runtime_configuration", forbidden) - replay = await worker._coordinator(factory).run(delivery) + replay = await (await worker._coordinator(factory)).run(delivery) assert replay == first assert runtime.calls == 1 async with factory() as session: @@ -157,7 +157,7 @@ def forbidden(*args): from app.modules.projects.api import ProjectGuideSetupFinalizationError with pytest.raises(ProjectGuideSetupFinalizationError) as denied: - await worker._coordinator(factory).run(delivery) + await (await worker._coordinator(factory)).run(delivery) assert denied.value.code == "service_authority_denied" assert runtime.calls == 1 @@ -188,7 +188,7 @@ def forbidden(*args): async with factory() as session: before = await session.scalar(text("select count(*) from audit_events")) with pytest.raises(ProjectGuideCompilationDeliveryError, match="^stale compilation delivery$"): - await worker._coordinator(factory).run(changed) + await (await worker._coordinator(factory)).run(changed) async with factory() as session: assert await session.scalar(text("select count(*) from audit_events")) == before for table in [ @@ -229,7 +229,7 @@ async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_ monkeypatch.setattr( worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration() ) - coordinator = worker._coordinator(factory) + coordinator = await worker._coordinator(factory) # Beat selects the exact delivery before terminal provider custody exists. await _select_stale_delivery(factory, delivery) first = await coordinator.run(delivery) @@ -300,7 +300,7 @@ async def test_finalized_receipt_excludes_every_recovery_shape(automatic_source, worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration() ) await _select_stale_delivery(factory, delivery) - receipt = await worker._coordinator(factory).run(delivery) + receipt = await (await worker._coordinator(factory)).run(delivery) assert receipt["status"] == "policy_draft_ready" async with factory() as session: evidence = await session.scalar( @@ -432,7 +432,7 @@ async def dispatch(): publisher = asyncio.create_task(dispatch()) try: assert await asyncio.to_thread(published.wait, 10) - receipt = await worker._coordinator(factory).run(delivery) + receipt = await (await worker._coordinator(factory)).run(delivery) assert receipt["status"] == "policy_draft_ready" async with factory() as session: before = await session.scalar( @@ -475,7 +475,7 @@ async def test_phase_crash_recovers_same_attempt_without_reinference( monkeypatch.setattr( worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration() ) - coordinator = worker._coordinator(factory) + coordinator = await worker._coordinator(factory) projections = coordinator._projections class Crash(Exception): @@ -572,7 +572,7 @@ async def test_each_phase_rechecks_service_authority_and_restoration_reuses_atte monkeypatch.setattr( worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration() ) - coordinator = worker._coordinator(factory) + coordinator = await worker._coordinator(factory) async def revoke(): async with factory() as session, session.begin(): @@ -673,17 +673,19 @@ async def blocked_provider(context, capabilities): monkeypatch.setattr( worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration() ) - first = asyncio.create_task(worker._coordinator(factory).run(delivery)) + first = asyncio.create_task((await worker._coordinator(factory)).run(delivery)) try: await asyncio.wait_for(entered.wait(), timeout=20) - duplicate = await asyncio.wait_for(worker._coordinator(factory).run(delivery), timeout=20) + duplicate = await asyncio.wait_for( + (await worker._coordinator(factory)).run(delivery), timeout=20 + ) assert duplicate["error_code"] == "provider_outcome_unresolved" finally: release.set() receipt = await asyncio.wait_for(first, timeout=30) assert receipt["status"] == "policy_draft_ready" assert runtime.calls == 1 - assert await worker._coordinator(factory).run(delivery) == receipt + assert await (await worker._coordinator(factory)).run(delivery) == receipt async with factory() as session: for table in [ "project_guide_compilation_attempts", @@ -727,7 +729,7 @@ def forbidden(*args): monkeypatch.setattr(worker, "create_project_guide_runtime", forbidden) monkeypatch.setattr(worker, "project_guide_runtime_configuration", forbidden) with pytest.raises(ProjectGuideCompilationDeliveryError): - await worker._coordinator(factory).run(delivery) + await (await worker._coordinator(factory)).run(delivery) async with factory() as session: for table in ( "project_guide_compilation_request_operations", @@ -757,7 +759,7 @@ def unavailable(settings): monkeypatch.setattr(worker, "project_guide_runtime_configuration", unavailable) for _ in range(4): with pytest.raises(ValueError, match="missing runtime configuration"): - await worker._coordinator(factory).run(delivery) + await (await worker._coordinator(factory)).run(delivery) async with factory() as session: assert ( await session.scalar(text("select count(*) from project_guide_compilation_attempts")) @@ -769,9 +771,9 @@ def unavailable(settings): worker, "project_guide_runtime_configuration", lambda settings: runtime_configuration() ) monkeypatch.setattr(worker, "create_project_guide_runtime", lambda configuration: runtime) - first = await worker._coordinator(factory).run(delivery) + first = await (await worker._coordinator(factory)).run(delivery) assert first["status"] == "policy_draft_ready" - assert await worker._coordinator(factory).run(delivery) == first + assert await (await worker._coordinator(factory)).run(delivery) == first assert runtime.calls == 1 assert delivery.source_snapshot_id not in await _published_continuation_snapshots(factory) diff --git a/backend/tests/projects/guide_compilation/test_live_worker.py b/backend/tests/projects/guide_compilation/test_live_worker.py index 4c0435e7a..d914a5629 100644 --- a/backend/tests/projects/guide_compilation/test_live_worker.py +++ b/backend/tests/projects/guide_compilation/test_live_worker.py @@ -116,7 +116,10 @@ async def run(self, delivery): raise ProjectGuideCompilationDeliveryError() monkeypatch.setattr(worker, "get_database_url", lambda: "postgresql+asyncpg://localhost/unit_test") - monkeypatch.setattr(worker, "_coordinator", lambda sessions: Coordinator()) + async def coordinator(_sessions): + return Coordinator() + + monkeypatch.setattr(worker, "_coordinator", coordinator) def forbidden(**kwargs): pytest.fail("stale delivery reached transport retry") diff --git a/backend/tests/projects/policy_bundle_fixtures.py b/backend/tests/projects/policy_bundle_fixtures.py index bb6ac8da5..0ae8264fc 100644 --- a/backend/tests/projects/policy_bundle_fixtures.py +++ b/backend/tests/projects/policy_bundle_fixtures.py @@ -24,7 +24,7 @@ async def create_approved_policy_bundle( sufficiency_status: str = "passed", artifact_proposal=None, request_headers=None, - post_submit_required_checkers=None, + post_submit_registry_spec=None, ) -> dict: snapshot = await read_guide_source_snapshot(project_id, guide_id) report = await create_sufficiency_report( @@ -36,7 +36,7 @@ async def create_approved_policy_bundle( request_headers=request_headers, ) policy = await create_unified_submission_policy(report["id"], snapshot["id"], proposal=artifact_proposal, - post_submit_required_checkers=post_submit_required_checkers or ()) + post_submit_registry_spec=post_submit_registry_spec) from app.db import session as db_session from app.modules.projects.models import ProjectSetupRun from sqlalchemy import select diff --git a/backend/tests/projects/policy_read_faults.py b/backend/tests/projects/policy_read_faults.py index 252819c13..66423aabe 100644 --- a/backend/tests/projects/policy_read_faults.py +++ b/backend/tests/projects/policy_read_faults.py @@ -52,21 +52,26 @@ async def corrupt_locked_policy_reads(monkeypatch, task_id, mutation): "package_required": "yes", "allowed_package_formats": "zip", } elif mutation == "bundle": - pre.compiled_bundle["rules"] = [ - rule for rule in pre.compiled_bundle["rules"] if rule["primitive"] != "require_file" - ] + pre.compiled_bundle["workstream_default"]["behavior_sha256"] = ( + f"sha256:{'f' * 64}" + ) elif mutation == "stale_effective": effective.effective_policy["required_evidence"] = [] elif mutation == "stale_bundle": pre.compiled_bundle["tampered"] = True else: raise ValueError("unknown policy read fault") - if mutation in {"schema", "evidence", "packaging", "bundle"}: + if mutation in {"schema", "evidence", "packaging"}: effective.effective_policy_hash = canonical_json_hash(effective.effective_policy) pre.effective_policy_hash = effective.effective_policy_hash - pre.compiled_bundle["effective_policy_hash"] = effective.effective_policy_hash - pre.compiled_bundle_hash = canonical_json_hash(pre.compiled_bundle) task.locked_effective_project_submission_artifact_policy_hash = effective.effective_policy_hash + if mutation == "bundle": + pre.compiled_bundle["policy_sha256"] = canonical_json_hash({ + key: value + for key, value in pre.compiled_bundle.items() + if key != "policy_sha256" + }) + pre.compiled_bundle_hash = pre.compiled_bundle["policy_sha256"] task.locked_pre_submit_checker_bundle_hash = pre.compiled_bundle_hash def replace_read(owner, name, row): diff --git a/backend/tests/projects/policy_read_fixtures.py b/backend/tests/projects/policy_read_fixtures.py index e7f5cc76f..17997151b 100644 --- a/backend/tests/projects/policy_read_fixtures.py +++ b/backend/tests/projects/policy_read_fixtures.py @@ -8,6 +8,35 @@ from uuid import uuid4 from app.core.hashing import canonical_json_hash +from app.modules.checkers.api.external_policy import ( + LockedExternalPostSubmitPolicy, + LockedExternalPreSubmitPolicy, + LockedWorkstreamDefault, +) + + +def _empty_external_pre_policy() -> LockedExternalPreSubmitPolicy: + default = LockedWorkstreamDefault() + payload = { + "schema_version": "external_pre_submit_policy.v1", + "workstream_default": default.model_dump(mode="json"), + "entries": [], + } + return LockedExternalPreSubmitPolicy( + **payload, + policy_sha256=canonical_json_hash(payload), + ) + + +def _empty_external_post_policy() -> LockedExternalPostSubmitPolicy: + payload = { + "schema_version": "external_post_submit_policy.v1", + "entries": [], + } + return LockedExternalPostSubmitPolicy( + **payload, + policy_sha256=canonical_json_hash(payload), + ) class _PolicyReadRepository: @@ -60,7 +89,8 @@ def _initialize_source(self) -> None: def _initialize_intake(self) -> None: submission_body = {"allowed": ["zip"]} effective_body = {"allowed": ["zip"], "max_bytes": 10} - checker_bundle = {"checkers": ["safe"]} + checker_policy = _empty_external_pre_policy() + checker_bundle = checker_policy.model_dump(mode="json") self.effective = types.SimpleNamespace( id=str(uuid4()), project_id=self.project_id, @@ -85,7 +115,7 @@ def _initialize_intake(self) -> None: effective_policy_hash=self.effective.effective_policy_hash, lifecycle_status="compiled", compiled_bundle=checker_bundle, - compiled_bundle_hash=canonical_json_hash(checker_bundle), + compiled_bundle_hash=checker_policy.policy_sha256, ) self.submission = types.SimpleNamespace( id=self.effective.submission_artifact_policy_id, @@ -115,7 +145,8 @@ def _initialize_review(self) -> None: warnings_acknowledged_at=None, warnings_acknowledged_by_role=None, ) - post_body = {"required_checkers": ["safe"]} + post_policy = _empty_external_post_policy() + post_body = post_policy.model_dump(mode="json") self.post_submit = types.SimpleNamespace( id=str(uuid4()), project_id=self.project_id, @@ -132,7 +163,7 @@ def _initialize_review(self) -> None: projection_operation_id=uuid4(), approved_at=datetime.now(UTC), policy_body=post_body, - policy_hash=canonical_json_hash(post_body), + policy_hash=post_policy.policy_sha256, ) self.review = types.SimpleNamespace( id=str(uuid4()), diff --git a/backend/tests/projects/post_policy/pg_support.py b/backend/tests/projects/post_policy/pg_support.py index 85e97f439..2a6602f4f 100644 --- a/backend/tests/projects/post_policy/pg_support.py +++ b/backend/tests/projects/post_policy/pg_support.py @@ -127,12 +127,14 @@ async def prepare_post_policy(factory, command, actor, grant, setup_actor): async def operate(factory, actor, project_id, grant, operation, command, *, close_error=False, guide_close_error=False): - from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.post_policy.service import PostPolicyService from tests.projects.guide_compilation.proposals.pg_support import ProposalAuthority async with factory() as session, session.begin(): - service = PostPolicyService(session, PostAuthority(session, actor, project_id, grant, close_error=close_error), current_post_submit_catalogue()) + service = PostPolicyService( + session, + PostAuthority(session, actor, project_id, grant, close_error=close_error), + ) kwargs = dict(actor=actor, request_id=uuid4()) if operation == "review_package": kwargs["contribution"] = GuideContributionPolicyDiscovery(published_contribution_policy_port(session)) diff --git a/backend/tests/projects/post_policy/test_compiler.py b/backend/tests/projects/post_policy/test_compiler.py deleted file mode 100644 index dcfebb6df..000000000 --- a/backend/tests/projects/post_policy/test_compiler.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Real saved-result compiler and discriminating selection enforcement proof.""" - -from copy import deepcopy -from uuid import uuid4 - -import pytest - -from app.interfaces.project_agents import ProjectGuideCompilationResult -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue -from app.modules.projects.post_policy.compiler import compile_saved_post_policy -from tests.projects.guide_compilation.helpers import result - - -def selected_result(): - body = result().model_dump(mode="json") - body['requirements'] = [dict(requirement_id='criteria', statement='Task acceptance criteria are present', - disposition='supported_post_submit', platform_coverage=None, evidence_refs=[])] - body['post_submit_bindings'] = [dict(requirement_id='criteria', capability_id='check_acceptance_criteria_present', - capability_version='v0.1', stage='post_submit', parameters=[])] - return body - - -def compile_body(body, catalogue=None): - return compile_saved_post_policy(project_id=uuid4(), guide_version='guide-one', - result=ProjectGuideCompilationResult.model_validate(body), - catalogue=catalogue or current_post_submit_catalogue()) - - -def test_shared_requirements_compile_once_with_all_bindings_preserved(): - body = selected_result() - body['requirements'].append(body['requirements'][0] | dict(requirement_id='criteria-two')) - body['post_submit_bindings'].append(body['post_submit_bindings'][0] | dict(requirement_id='criteria-two')) - compiled = compile_body(body) - assert compiled.required_checkers == ['check_acceptance_criteria_present'] - assert len(compiled.entries) == 9 - assert len(body['post_submit_bindings']) == 2 - assert compiled.entries[-1].configuration.model_dump() == {} - - -@pytest.mark.parametrize('change', ['missing', 'unknown', 'stage', 'version', 'default', 'configuration', 'disposition', 'duplicate_requirement']) -def test_compiler_revalidates_each_malformed_binding(change): - body = selected_result() - assert len(compile_body(body).entries) == 9 - binding = body['post_submit_bindings'][0] - if change == 'missing': - body['post_submit_bindings'] = [] - elif change == 'unknown': - binding['capability_id'] = 'unsupported' - elif change == 'stage': - binding['stage'] = 'pre_submit' - elif change == 'version': - binding['capability_version'] = 'wrong' - elif change == 'default': - binding['capability_id'] = 'check_submission_packet' - elif change == 'configuration': - binding['parameters'] = [dict(name='disable', value=True)] - elif change == 'disposition': - body['requirements'][0]['disposition'] = 'human_review' - else: - body['requirements'].append(deepcopy(body['requirements'][0])) - with pytest.raises(ValueError): - compile_body(body) - - -def test_compiler_rejects_a_real_compiler_that_omits_the_required_selection(monkeypatch): - from app.modules.projects.post_policy import compiler - from app.modules.projects.post_submit_policy import compile_project_post_submit_checker_spec - - def missing_selection(**kwargs): - kwargs['spec'] = kwargs['spec'] | dict(required_checkers=[]) - return compile_project_post_submit_checker_spec(**kwargs) - - monkeypatch.setattr(compiler, 'compile_project_post_submit_checker_spec', missing_selection) - with pytest.raises(ValueError, match='omits or changes a required binding'): - compile_body(selected_result()) - - -@pytest.mark.parametrize('index', [0, 8]) -def test_disabled_default_or_selected_capability_is_unavailable(index): - from tests.checkers.post_submit.support import altered_catalogue - - with pytest.raises(ValueError, match='unavailable'): - compile_body(selected_result(), altered_catalogue(index=index, state='disabled')) - - -def test_blocked_result_never_yields_a_policy(): - body = selected_result() - body['status'] = 'guide_blocked' - body['submission_artifact_policy'] = None - body['requirements'].append(dict(requirement_id='gap', statement='The guide lacks necessary task context', - disposition='guide_blocker', platform_coverage=None, evidence_refs=[])) - with pytest.raises(ValueError, match='blocked guide cannot project'): - compile_body(body) diff --git a/backend/tests/projects/post_policy/test_direct_sql.py b/backend/tests/projects/post_policy/test_direct_sql.py deleted file mode 100644 index 39f3051e3..000000000 --- a/backend/tests/projects/post_policy/test_direct_sql.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Bypass ORM persistence with complete otherwise-valid approval output custody.""" - -import json -from datetime import UTC, datetime -from uuid import uuid4 - -import pytest -from sqlalchemy import text -from sqlalchemy.exc import DBAPIError - -from app.core.identifiers import new_record_id -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue -from app.modules.projects.api.post_policy import PostPolicyApproval -from app.modules.projects.post_policy.models import PostPolicyOperation -from app.modules.projects.post_policy.service import PostPolicyService -from tests.projects.guide_compilation.helpers import service_actor -from tests.projects.guide_compilation.proposals.pg_support import proposal_case -from .pg_support import PostAuthority, prepare_post_policy, operate -from .test_authority import foreign_manager, state - - -@pytest.mark.parametrize('change,error', [ - ('output_digest', 'post-policy operation lineage mismatch'), - ('foreign_grant', 'guide proposal current authority missing'), - ('wrong_action', 'post-policy authority evidence mismatch'), -]) -async def test_direct_sql_rejects_one_substituted_commitment(clean_postgres_database, monkeypatch, change, error): - async with proposal_case(clean_postgres_database) as (values, factory, command, actor, grant): - _, projected = await prepare_post_policy(factory, command, actor, grant, service_actor(values)) - _, _, _, foreign_grant = await foreign_manager(factory, actor) - payload = PostPolicyApproval(target=projected.target, idempotency_key=uuid4()) - before = await state(factory) - async with factory() as session: - await session.begin() - captured = [] - original_add = session.add - - def capture_operation(row, **kwargs): - if isinstance(row, PostPolicyOperation): - captured.append(row) - else: - original_add(row, **kwargs) - - try: - # Arrange actual outputs and decision; capture only the yet-unpersisted operation. - with monkeypatch.context() as patch: - patch.setattr(session, 'add', capture_operation) - service = PostPolicyService(session, PostAuthority(session, actor, command.project_id, grant), current_post_submit_catalogue()) - receipt = await service.approve(payload, actor=actor, request_id=uuid4()) - assert len(captured) == 1 - row = {c.name: getattr(captured[0], c.name) for c in PostPolicyOperation.__table__.columns} - row['created_at'] = datetime.now(UTC) - assert row['operation_id'] == receipt.operation_id - if change == 'output_digest': - row['output_digest'] = 'sha256:'+'b'*64 - elif change == 'foreign_grant': - row['admin_role_grant_id'] = foreign_grant - else: - wrong_event = str(new_record_id()) - await session.execute(text( - 'INSERT INTO audit_events SELECT (jsonb_populate_record(NULL::audit_events, ' - 'to_jsonb(source)||CAST(:patch AS jsonb))).* FROM audit_events source WHERE id=:id'), - dict(id=row['authorization_decision_event_id'], patch=json.dumps(dict( - id=wrong_event, entity_id=wrong_event, - action_id='project.post_submit_checker_policy.correction.request')))) - row['authorization_decision_event_id'] = wrong_event - await session.execute(text( - 'INSERT INTO project_post_policy_operations SELECT ' - '(jsonb_populate_record(NULL::project_post_policy_operations, CAST(:row AS jsonb))).*'), - dict(row=json.dumps(row, default=str))) - # All immediate constraints pass; force this guard before other deferred constraints. - with pytest.raises(DBAPIError, match=error): - await session.execute(text('SET CONSTRAINTS post_policy_operation_custody IMMEDIATE')) - finally: - await session.rollback() - assert await state(factory) == before - assert (await operate(factory, actor, command.project_id, grant, 'approve', payload)).kind == 'approve' - - -@pytest.mark.parametrize('classification', ['project_required', 'project_warning']) -async def test_database_rejects_unrequested_project_selection(clean_postgres_database, monkeypatch, classification): - from app.modules.projects.post_policy import service - from app.modules.projects.post_submit_policy import ( - build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec, - ) - from .pg_support import prepare_upstream - - async with proposal_case(clean_postgres_database) as (values, factory, command, actor, grant): - payload = await prepare_upstream(factory, command, actor, grant) - before = await state(factory) - calls = [] - - def extra_selection(*, project_id, guide_version, result, catalogue): - assert result.post_submit_bindings == () - field = 'required_checkers' if classification == 'project_required' else 'warning_checkers' - compiled = compile_project_post_submit_checker_spec( - project_id=str(project_id), guide_version=guide_version, - spec=build_project_post_submit_checker_spec( - project_id=str(project_id), guide_version=guide_version, - **{field: ['check_acceptance_criteria_present']}), - ) - compiled.validate_catalogue(catalogue) - calls.append(compiled) - return compiled - - with monkeypatch.context() as patch: - # Faulty compiler output remains a valid canonical body with real hash/custody. - patch.setattr(service, 'compile_saved_post_policy', extra_selection) - with pytest.raises(DBAPIError, match='post-policy compiled selection not requested'): - await operate(factory, service_actor(values), command.project_id, None, 'derive', payload) - assert len(calls) == 1 - assert calls[0].entries[-1].classification == classification - assert await state(factory) == before - # Platform defaults remain valid with no project bindings, including separate approval. - projected = await operate(factory, service_actor(values), command.project_id, None, 'derive', payload) - approved = await operate(factory, actor, command.project_id, grant, 'approve', - PostPolicyApproval(target=projected.target, idempotency_key=uuid4())) - assert approved.kind == 'approve' - - -@pytest.mark.parametrize('binding_count', [1, 2]) -async def test_database_accepts_exact_selection_shared_by_requirements(clean_postgres_database, binding_count): - from app.interfaces.project_agents import ProjectGuideCompilationResult - from .test_compiler import selected_result - - body = selected_result() - if binding_count == 2: - body['requirements'].append(body['requirements'][0] | dict(requirement_id='criteria-two')) - body['post_submit_bindings'].append(body['post_submit_bindings'][0] | dict(requirement_id='criteria-two')) - outcome = ProjectGuideCompilationResult.model_validate(body) - async with proposal_case(clean_postgres_database, outcome=outcome) as (values, factory, command, actor, grant): - _, projected = await prepare_post_policy(factory, command, actor, grant, service_actor(values)) - approved = await operate(factory, actor, command.project_id, grant, 'approve', - PostPolicyApproval(target=projected.target, idempotency_key=uuid4())) - async with factory() as session: - policy = await session.scalar(text('SELECT policy_body FROM checker_policies WHERE id=:id'), - dict(id=str(projected.target.policy_id))) - selections = [entry for entry in policy['entries'] if entry['classification'] != 'platform_default'] - assert len(selections) == 1 - assert selections[0]['checker_id'] == 'check_acceptance_criteria_present' - assert selections[0]['classification'] == 'project_required' - assert approved.kind == 'approve' - - -async def test_database_rejects_missing_required_selection(clean_postgres_database, monkeypatch): - from app.interfaces.project_agents import ProjectGuideCompilationResult - from app.modules.projects.post_policy import service - from app.modules.projects.post_submit_policy import ( - build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec, - ) - from .pg_support import prepare_upstream - from .test_compiler import selected_result - - async with proposal_case(clean_postgres_database, outcome=ProjectGuideCompilationResult.model_validate( - selected_result() - )) as (values, factory, command, actor, grant): - payload = await prepare_upstream(factory, command, actor, grant) - before = await state(factory) - calls = [] - - def missing_selection(*, project_id, guide_version, result, catalogue): - assert len(result.post_submit_bindings) == 1 - compiled = compile_project_post_submit_checker_spec( - project_id=str(project_id), guide_version=guide_version, - spec=build_project_post_submit_checker_spec(project_id=str(project_id), guide_version=guide_version)) - compiled.validate_catalogue(catalogue) - calls.append(compiled) - return compiled - - with monkeypatch.context() as patch: - patch.setattr(service, 'compile_saved_post_policy', missing_selection) - with pytest.raises(DBAPIError, match='post-policy compiled required binding missing'): - await operate(factory, service_actor(values), command.project_id, None, 'derive', payload) - assert len(calls) == 1 - assert await state(factory) == before - assert (await operate(factory, service_actor(values), command.project_id, None, 'derive', payload)).kind == 'derive' diff --git a/backend/tests/projects/post_policy/test_guards.py b/backend/tests/projects/post_policy/test_guards.py index 75fd74f65..bd7373acf 100644 --- a/backend/tests/projects/post_policy/test_guards.py +++ b/backend/tests/projects/post_policy/test_guards.py @@ -1,23 +1,29 @@ """Reachable downstream negatives with complete finalized and approved source custody.""" +import json from dataclasses import replace +from datetime import UTC, datetime from uuid import uuid4 import pytest from sqlalchemy import text from sqlalchemy.exc import DBAPIError -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue +from app.core.identifiers import new_record_id from app.modules.projects.api.guide_proposals import GuideProposalError, GuideProposalSelection from app.modules.projects.api.post_policy import ( PostPolicyApproval, PostPolicyDerive, post_policy_human_authorization_selector, ) +from app.modules.projects.post_policy.models import PostPolicyOperation from app.modules.projects.post_policy.service import PostPolicyService +from tests.checkers.external.support import post_registry_spec +from tests.projects.guide_activation.source_fixtures import source_case from tests.projects.guide_compilation.helpers import service_actor from tests.projects.guide_compilation.proposals.pg_support import proposal_case, revoke_review_grant from .pg_support import PostAuthority, operate, prepare_post_policy +from .test_authority import foreign_manager, state async def test_valid_finalization_without_upstream_approval_cannot_project(clean_postgres_database): @@ -38,7 +44,7 @@ async def test_missing_approval_receipt_hits_the_specific_deferred_guard(clean_p payload = PostPolicyApproval(target=projected.target, idempotency_key=uuid4()) async with factory() as session: await session.begin() - service = PostPolicyService(session, PostAuthority(session, actor, command.project_id, grant), current_post_submit_catalogue()) + service = PostPolicyService(session, PostAuthority(session, actor, command.project_id, grant)) async def omit_only_operation(*args): # Preserve actual policy outputs and the real prepared audit decision. @@ -67,6 +73,129 @@ async def omit_only_operation(*args): assert (await operate(factory, actor, command.project_id, grant, 'approve', payload)).kind == 'approve' +@pytest.mark.parametrize('change,error', [ + ('output_digest', 'post-policy operation lineage mismatch'), + ('foreign_grant', 'guide proposal current authority missing'), + ('wrong_action', 'post-policy authority evidence mismatch'), +]) +async def test_direct_sql_rejects_one_substituted_external_policy_commitment( + clean_postgres_database, monkeypatch, change, error, +): + selected_spec = post_registry_spec() + async with source_case( + clean_postgres_database, + post_submit_registry_spec=selected_spec, + ) as (values, factory, command, actor, grant): + _, projected = await prepare_post_policy(factory, command, actor, grant, service_actor(values)) + async with factory() as session: + stored = ( + await session.execute( + text( + 'SELECT policy.policy_body, registry.id AS registry_entry_id, ' + 'registry.entry_digest FROM checker_policies policy ' + 'JOIN external_checker_registry_entries registry ' + 'ON registry.capability_id=:capability_id ' + 'AND registry.capability_version=:capability_version ' + "AND registry.phase='post_submit' WHERE policy.id=:policy_id" + ), + dict( + capability_id=selected_spec.capability_id, + capability_version=selected_spec.capability_version, + policy_id=str(projected.target.policy_id), + ), + ) + ).mappings().one() + policy_body = stored['policy_body'] + assert policy_body['schema_version'] == 'external_post_submit_policy.v1' + assert len(policy_body['entries']) == 1 + locked_registry = policy_body['entries'][0]['registry'] + assert locked_registry['capability_id'] == selected_spec.capability_id + assert locked_registry['registry_entry_id'] == str(stored['registry_entry_id']) + assert locked_registry['entry_digest'] == stored['entry_digest'] + _, _, _, foreign_grant = await foreign_manager(factory, actor) + payload = PostPolicyApproval(target=projected.target, idempotency_key=uuid4()) + before = await state(factory) + async with factory() as session: + await session.begin() + captured = [] + original_add = session.add + + def capture_operation(row, **kwargs): + if isinstance(row, PostPolicyOperation): + captured.append(row) + else: + original_add(row, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(session, 'add', capture_operation) + service = PostPolicyService( + session, + PostAuthority(session, actor, command.project_id, grant), + ) + receipt = await service.approve(payload, actor=actor, request_id=uuid4()) + assert len(captured) == 1 + row = { + column.name: getattr(captured[0], column.name) + for column in PostPolicyOperation.__table__.columns + } + row['created_at'] = datetime.now(UTC) + assert row['operation_id'] == receipt.operation_id + if change == 'output_digest': + row['output_digest'] = 'sha256:' + 'b' * 64 + elif change == 'foreign_grant': + row['admin_role_grant_id'] = foreign_grant + else: + wrong_event = str(new_record_id()) + await session.execute( + text( + 'INSERT INTO audit_events SELECT ' + '(jsonb_populate_record(NULL::audit_events, ' + 'to_jsonb(source)||CAST(:patch AS jsonb))).* ' + 'FROM audit_events source WHERE id=:id' + ), + dict( + id=row['authorization_decision_event_id'], + patch=json.dumps(dict( + id=wrong_event, + entity_id=wrong_event, + action_id='project.post_submit_checker_policy.correction.request', + )), + ), + ) + row['authorization_decision_event_id'] = wrong_event + await session.execute( + text( + 'INSERT INTO project_post_policy_operations SELECT ' + '(jsonb_populate_record(' + 'NULL::project_post_policy_operations, CAST(:row AS jsonb))).*' + ), + dict(row=json.dumps(row, default=str)), + ) + with pytest.raises(DBAPIError, match=error) as refused: + await session.execute( + text('SET CONSTRAINTS post_policy_operation_custody IMMEDIATE') + ) + assert refused.value.orig.sqlstate == '23514' + finally: + await session.rollback() + assert await state(factory) == before + recovered = await operate(factory, actor, command.project_id, grant, 'approve', payload) + assert recovered.kind == 'approve' + assert recovered.operation_id != receipt.operation_id + async with factory() as session: + durable = ( + await session.execute( + text( + "SELECT operation_id,authorization_decision_event_id FROM " + "project_post_policy_operations WHERE kind='approve'" + ) + ) + ).mappings().one() + assert durable['operation_id'] == recovered.operation_id + assert durable['authorization_decision_event_id'] != row['authorization_decision_event_id'] + + async def test_revoked_manager_cannot_approve_or_replay(clean_postgres_database): async with proposal_case(clean_postgres_database) as (values, factory, command, actor, grant): _, projected = await prepare_post_policy(factory, command, actor, grant, service_actor(values)) @@ -96,10 +225,8 @@ async def test_retained_policy_and_operation_evidence_is_immutable(clean_postgre await session.execute(text(statement)) -@pytest.mark.parametrize('change', ['upstream', 'catalogue', 'project', 'link', 'policy_hash']) +@pytest.mark.parametrize('change', ['upstream', 'project', 'link', 'policy_hash']) async def test_exact_source_and_authority_substitutions_reject_without_writes(clean_postgres_database, change): - from tests.checkers.post_submit.support import altered_catalogue - async with proposal_case(clean_postgres_database) as (values, factory, command, actor, grant): derive, projected = await prepare_post_policy(factory, command, actor, grant, service_actor(values)) payload = PostPolicyApproval(target=projected.target, idempotency_key=uuid4()) @@ -110,13 +237,12 @@ async def test_exact_source_and_authority_substitutions_reject_without_writes(cl await operate(factory, service_actor(values), command.project_id, None, 'derive', bad) else: async with factory() as session, session.begin(): - catalogue = altered_catalogue(index=8, state='disabled') if change == 'catalogue' else current_post_submit_catalogue() authority = PostAuthority(session, actor, uuid4() if change == 'project' else command.project_id, grant) caller = replace(actor, identity_link_id=uuid4()) if change == 'link' else actor if change == 'policy_hash': payload = PostPolicyApproval(target=projected.target.model_copy(update={'policy_hash': 'sha256:'+'b'*64}), idempotency_key=uuid4()) - service = PostPolicyService(session, authority, catalogue) + service = PostPolicyService(session, authority) with pytest.raises(GuideProposalError, match='authority_unavailable' if change in {'project','link'} else 'proposal_stale'): await service.approve(payload, actor=caller, request_id=uuid4()) async with factory() as session: diff --git a/backend/tests/projects/post_policy/test_postgresql.py b/backend/tests/projects/post_policy/test_postgresql.py index 2e7cea042..9c84cf524 100644 --- a/backend/tests/projects/post_policy/test_postgresql.py +++ b/backend/tests/projects/post_policy/test_postgresql.py @@ -7,12 +7,16 @@ from uuid import uuid4 +import pytest from sqlalchemy import text -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.api.guide_proposals import GuideProposalApproval, GuideProposalSelection from app.modules.projects.api.post_policy import PostPolicyApproval, PostPolicyDerive, PostPolicySelection from app.modules.projects.post_policy.service import PostPolicyService +from app.modules.projects.post_policy.compiler import compile_saved_post_policy +from app.modules.checkers.api.external_policy import validate_stored_external_checker_policies +from app.interfaces.project_agents import ProjectGuideCompilationResult +from tests.checkers.external.support import post_registry_spec from tests.projects.guide_compilation.helpers import service_actor from tests.projects.guide_compilation.proposals.pg_support import read_package from tests.projects.guide_compilation.proposals.test_postgresql import approve @@ -20,7 +24,7 @@ async def test_post_policy_operation_preserves_finalization_without_provider_or_evaluator_calls(clean_postgres_database, monkeypatch): - async with source_case(clean_postgres_database) as (values, factory, command, actor, grant): + async with source_case(clean_postgres_database, post_submit_registry_spec=post_registry_spec()) as (values, factory, command, actor, grant): package = await read_package(factory, command, actor, grant) upstream = await approve(factory, command, actor, grant, GuideProposalApproval(target=package.target, idempotency_key=uuid4())) @@ -38,28 +42,45 @@ async def forbidden(*args, **kwargs): monkeypatch.setattr(OpenAIAgentSdkProjectGuideRuntime, 'compile_project_guide', forbidden) monkeypatch.setattr(ScopedGuideDocumentGrant, 'open', forbidden) - from app.adapters.artifacts import CheckerPhaseService - monkeypatch.setattr(CheckerPhaseService, 'evaluate_post_submission', forbidden) + from app.modules.checkers.execution import PostSubmissionExecutor + monkeypatch.setattr(PostSubmissionExecutor, 'evaluate_post_submission', forbidden) original = await stored_state(factory, command) receipts = [] for _ in range(2): async with factory() as session, session.begin(): - service = PostPolicyService(session, PostAuthority(session, service_actor(values), command.project_id), current_post_submit_catalogue()) + service = PostPolicyService(session, PostAuthority(session, service_actor(values), command.project_id)) receipts.append(await service.derive(payload, actor=service_actor(values), request_id=uuid4())) assert receipts[0] == receipts[1] target = receipts[0].target async with factory() as session, session.begin(): - service = PostPolicyService(session, PostAuthority(session, actor, command.project_id, grant), current_post_submit_catalogue()) + service = PostPolicyService(session, PostAuthority(session, actor, command.project_id, grant)) draft = await service.review_package(PostPolicySelection(**payload.selection.model_dump(), policy_id=target.policy_id), actor=actor, request_id=uuid4(), contribution=GuideContributionPolicyDiscovery(published_contribution_policy_port(session))) assert draft.current and draft.lifecycle_status == 'compiled' - assert draft.policy.policy_hash == target.policy_hash - assert len(draft.policy.entries) == 8 + assert draft.policy.policy_sha256 == target.policy_hash + async with factory() as session: + saved = await session.scalar(text( + "SELECT canonical_result FROM project_guide_compilations WHERE id=:id" + ), {"id": command.compilation_id}) + approved_plan = await session.scalar(text( + "SELECT effective_pre_submit_plan FROM project_guide_proposal_approvals WHERE operation_id=:id" + ), {"id": upstream.operation_id}) + policies = validate_stored_external_checker_policies(approved_plan) + assert draft.policy == policies.post_submit + assert len(draft.policy.entries) == 1 + assert draft.policy.entries[0].registry.capability_id == post_registry_spec().capability_id + saved_result = ProjectGuideCompilationResult.model_validate(saved) + changed_result = saved_result.model_copy(update={ + "post_submit_bindings": tuple(binding.model_copy(update={"capability_version": "v9.9.9"}) + for binding in saved_result.post_submit_bindings), + }) + with pytest.raises(ValueError, match="changes a required binding"): + compile_saved_post_policy(result=changed_result, policies=policies) assert draft.proposal.target == package.target approval = PostPolicyApproval(target=target, idempotency_key=uuid4()) approved = [] for _ in range(2): async with factory() as session, session.begin(): - service = PostPolicyService(session, PostAuthority(session, actor, command.project_id, grant), current_post_submit_catalogue()) + service = PostPolicyService(session, PostAuthority(session, actor, command.project_id, grant)) approved.append(await service.approve(approval, actor=actor, request_id=uuid4())) assert approved[0] == approved[1] async with factory() as session: diff --git a/backend/tests/projects/post_policy/test_public_api.py b/backend/tests/projects/post_policy/test_public_api.py index 8d78154f7..3200f19ef 100644 --- a/backend/tests/projects/post_policy/test_public_api.py +++ b/backend/tests/projects/post_policy/test_public_api.py @@ -7,7 +7,7 @@ from app.main import create_app from app.modules.projects.post_policy.compiler import compile_saved_post_policy -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue +from app.modules.checkers.api.external_policy import validate_stored_external_checker_policies from app.interfaces.project_agents import ProjectGuideCompilationResult from tests.projects.guide_compilation.proposals.pg_support import revoke_review_grant, seed_review_actor from tests.projects.guide_compilation.proposals.public_support import proposal_client, proposal_path @@ -81,12 +81,15 @@ async def test_public_policy_body_separate_approval_replay_and_revocation( assert body["target"]["upstream"]["operation_id"] == str(approval_id) async with factory() as session: saved = await session.scalar(text("SELECT canonical_result FROM project_guide_compilations WHERE id=:id"), {"id":command.compilation_id}) + approved_plan = await session.scalar(text( + "SELECT effective_pre_submit_plan FROM project_guide_proposal_approvals WHERE operation_id=:id" + ), {"id": approval_id}) expected = compile_saved_post_policy( - project_id=command.project_id, guide_version=body["target"]["proposal"]["guide_version"], - result=ProjectGuideCompilationResult.model_validate(saved), catalogue=current_post_submit_catalogue(), + result=ProjectGuideCompilationResult.model_validate(saved), + policies=validate_stored_external_checker_policies(approved_plan), ) assert body["policy"] == expected.model_dump(mode="json") - assert body["target"]["policy_hash"] == expected.policy_hash + assert body["target"]["policy_hash"] == expected.policy_sha256 assert await session.scalar(text("SELECT count(*) FROM project_post_policy_operations WHERE kind='derive'")) == 1 headers = {"Idempotency-Key": str(uuid4())} payload = {"target": body["target"]} diff --git a/backend/tests/projects/post_submit_fixtures.py b/backend/tests/projects/post_submit_fixtures.py index 6c21c46c0..83ef6f10f 100644 --- a/backend/tests/projects/post_submit_fixtures.py +++ b/backend/tests/projects/post_submit_fixtures.py @@ -61,9 +61,9 @@ def crossed_post_policy_read(policy_id): def loaded(_session, row): if isinstance(row, PostSubmitCheckerPolicy) and row.id == policy_id: seen.append(row.id) - set_committed_value(row, 'required_checkers', [ - *row.required_checkers, 'check_acceptance_criteria_present', - ]) + set_committed_value(row, 'policy_body', { + **row.policy_body, 'policy_sha256': 'sha256:' + '0' * 64, + }) event.listen(Session, 'loaded_as_persistent', loaded) try: diff --git a/backend/tests/projects/review_policy/test_activation.py b/backend/tests/projects/review_policy/test_activation.py deleted file mode 100644 index 1e757b4f2..000000000 --- a/backend/tests/projects/review_policy/test_activation.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Current activation checks exact versioned semantics before mode availability.""" - -from copy import deepcopy -from types import SimpleNamespace - -import pytest - -from app.modules.projects.api.policy_lineage import ( - ReviewPolicySemantics, - RevisionPolicySemantics, - policy_digest, -) -from app.modules.projects.service import GuideActivationBlocked, ProjectService -from projects.test_activation_readiness import _activation_ready_bundle -from test_project_policy_mutations import _review_payload, _revision_payload - - -@pytest.mark.parametrize("format", ["v1", "v2"]) -def test_current_validator_accepts_true_then_rejects_valid_false(monkeypatch, format): - bundle = _activation_ready_bundle() - review = ReviewPolicySemantics.model_validate(_review_payload().model_dump()) - revision = RevisionPolicySemantics.model_validate(_revision_payload().model_dump()) - bundle["review_policy"] = SimpleNamespace( - **review.model_dump(), - semantics_status="complete", - semantics_format=format, - policy_hash=policy_digest("review", review, review_semantics_format=format), - ) - bundle["revision_policy"] = SimpleNamespace( - **revision.model_dump(), - semantics_status="complete", - policy_hash=policy_digest("revision", revision), - ) - service = ProjectService(None) - monkeypatch.setattr( - service, - "_merge_effective_submission_artifact_policy", - lambda _: deepcopy(bundle["effective_policy"].effective_policy), - ) - # The complete-policy validator remains real for both the positive and negative. - service.validate_activation_ready(**bundle) - false = ReviewPolicySemantics.model_validate( - {**review.model_dump(), "human_review_required": False} - ) - bundle["review_policy"] = SimpleNamespace( - **false.model_dump(), - semantics_status="complete", - semantics_format="v2", - policy_hash=policy_digest("review", false), - ) - with pytest.raises(GuideActivationBlocked, match="^automated acceptance is unavailable$"): - service.validate_activation_ready(**bundle) diff --git a/backend/tests/projects/review_policy/test_migration.py b/backend/tests/projects/review_policy/test_migration.py index d32d8889c..204145cf5 100644 --- a/backend/tests/projects/review_policy/test_migration.py +++ b/backend/tests/projects/review_policy/test_migration.py @@ -3,7 +3,6 @@ import asyncio from pathlib import Path import runpy -from uuid import uuid4 from alembic import command from alembic.migration import MigrationContext @@ -13,36 +12,13 @@ from sqlalchemy.exc import IntegrityError from app.db import session as db_session -from app.modules.actors.models import ActorIdentityLink, ActorProfile -from app.modules.actors.service import ResolvedActor -from app.modules.authorization.kernel import AuthorizationService -from app.modules.authorization.prepared import PreparedAuthorizationService -from app.modules.authorization.repository import AdminAuthorizationRepository -from app.modules.authorization.runtime import ( - ActorKind, - ActorStatus, - HumanAuthorizationContext, - IdentityLinkStatus, -) -from app.modules.projects.api.policy_lineage import ReviewPolicySemantics -from app.modules.projects.policy_mutation_service import ( - NO_CURRENT_POLICY_ETAG, - ProjectPolicyMutationService, -) -from app.modules.projects.schemas import ReviewPolicyInput, ReviewPolicyResponse from tests.conftest import _drop_test_database_schema +from tests.historical_submission_fixtures import restore_archived_snapshot from tests.migration_fixtures import _config, current_schema_revision -from tests.projects.guide_activation.source_fixtures import source_case pytestmark = pytest.mark.postgres_schema_contract -class _PreInvariantReviewPolicyResponse(ReviewPolicyResponse): - """Decode the predecessor's once-permitted retained true value.""" - - requires_second_review: bool - - def _upgrade(connection) -> None: migration = runpy.run_path( str( @@ -54,62 +30,6 @@ def _upgrade(connection) -> None: migration["upgrade"]() -async def _seed_review_policy(factory, command_context, actor, *, retained_true: bool) -> None: - async with factory() as session: - resolved = ResolvedActor( - await session.get(ActorProfile, str(actor.actor_profile_id)), - await session.get(ActorIdentityLink, str(actor.identity_link_id)), - ) - context = HumanAuthorizationContext( - actor_profile_id=actor.actor_profile_id, - actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, - identity_link_id=actor.identity_link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=uuid4(), - correlation_id=uuid4(), - ) - repository = AdminAuthorizationRepository(session) - kernel = AuthorizationService(session, context, admin_repository=repository) - prepared = PreparedAuthorizationService(session, context, kernel, repository) - service = ProjectPolicyMutationService(session) - values = ReviewPolicyInput( - review_preference_window_seconds=3600, - review_lease_duration_seconds=1800, - allowed_decisions=["accept", "needs_revision", "reject"], - ).model_dump() - if retained_true: - values["requires_second_review"] = True - values["allowed_decisions"] = tuple(values["allowed_decisions"]) - values["minimum_finding_fields"] = tuple(values["minimum_finding_fields"]) - # Construct the exact predecessor semantics to create a committed - # historical fact through the real writer, authority, and custody - # transaction. Current validation is exercised separately. - semantics = ReviewPolicySemantics.model_construct(**values) - await service._replace( - "review", - resolved, - prepared, - uuid4(), - NO_CURRENT_POLICY_ETAG, - command_context.project_id, - command_context.guide_id, - semantics, - _PreInvariantReviewPolicyResponse, - ) - else: - await service.replace_review_policy( - resolved, - prepared, - uuid4(), - NO_CURRENT_POLICY_ETAG, - command_context.project_id, - command_context.guide_id, - ReviewPolicyInput.model_validate(values), - ) - await session.commit() - - async def _snapshot(connection) -> dict[str, object]: return { "policies": list( @@ -137,6 +57,16 @@ async def _snapshot(connection) -> dict[str, object]: } +def _require_retained_policy(snapshot, *, requires_second_review): + """Prevent an empty or unselected fixture from satisfying preservation.""" + assert len(snapshot["policies"]) == len(snapshot["selectors"]) == 1 + policy, selector = snapshot["policies"][0], snapshot["selectors"][0] + assert policy["requires_second_review"] is requires_second_review + assert selector["policy_id"] == policy["id"] + assert selector["generation"] == policy["policy_generation"] + assert selector["hash"] == policy["policy_hash"] + + async def test_upgrade_refuses_retained_true_without_changing_its_lineage( isolated_database_env, migration_lock, @@ -147,24 +77,20 @@ async def test_upgrade_refuses_retained_true_without_changing_its_lineage( await asyncio.to_thread( command.upgrade, _config(), "0023_remove_task_payment_policy" ) - async with source_case(isolated_database_env) as ( - _values, - factory, - command_context, - actor, - _grant, - ): - await _seed_review_policy(factory, command_context, actor, retained_true=True) - async with factory.kw["bind"].connect() as connection, connection.begin(): - before = await _snapshot(connection) - assert before["policies"][0]["requires_second_review"] is True - with pytest.raises( - IntegrityError, - match="retained requires_second_review=true policy", - ): - async with connection.begin_nested(): - await connection.run_sync(_upgrade) - assert await _snapshot(connection) == before + await restore_archived_snapshot( + isolated_database_env, + snapshot_name="0023_review_policy_second_review_true", + ) + async with db_session.get_engine().connect() as connection, connection.begin(): + before = await _snapshot(connection) + _require_retained_policy(before, requires_second_review=True) + with pytest.raises( + IntegrityError, + match="retained requires_second_review=true policy", + ): + async with connection.begin_nested(): + await connection.run_sync(_upgrade) + assert await _snapshot(connection) == before async def test_upgrade_installs_false_guard_without_changing_hash_or_lineage( @@ -177,30 +103,28 @@ async def test_upgrade_installs_false_guard_without_changing_hash_or_lineage( await asyncio.to_thread( command.upgrade, _config(), "0023_remove_task_payment_policy" ) - async with source_case(isolated_database_env) as ( - _values, - factory, - command_context, - actor, - _grant, - ): - await _seed_review_policy(factory, command_context, actor, retained_true=False) - async with factory.kw["bind"].connect() as connection: - before = await _snapshot(connection) - await connection.rollback() - await asyncio.to_thread(command.upgrade, _config(), "head") - async with factory.kw["bind"].connect() as connection: - after = await _snapshot(connection) - constraint = await connection.scalar( - text( - "select pg_get_constraintdef(oid) from pg_constraint " - "where conrelid='public.review_policies'::regclass " - "and conname=" - "'ck_review_policies_review_policy_second_review_disabled'" - ) + await restore_archived_snapshot( + isolated_database_env, + snapshot_name="0023_review_policy_second_review_false", + ) + async with db_session.get_engine().connect() as connection: + before = await _snapshot(connection) + _require_retained_policy(before, requires_second_review=False) + await connection.rollback() + await db_session.dispose_engine() + await asyncio.to_thread(command.upgrade, _config(), "head") + async with db_session.get_engine().connect() as connection: + after = await _snapshot(connection) + constraint = await connection.scalar( + text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='public.review_policies'::regclass " + "and conname=" + "'ck_review_policies_review_policy_second_review_disabled'" ) - assert before["policies"] == after["policies"] - assert before["selectors"] == after["selectors"] - assert before["version"] == "0023_remove_task_payment_policy" - assert after["version"] == current_schema_revision() - assert constraint == "CHECK ((NOT requires_second_review))" + ) + assert before["policies"] == after["policies"] + assert before["selectors"] == after["selectors"] + assert before["version"] == "0023_remove_task_payment_policy" + assert after["version"] == current_schema_revision() + assert constraint == "CHECK ((NOT requires_second_review))" diff --git a/backend/tests/projects/test_activation_readiness.py b/backend/tests/projects/test_activation_readiness.py deleted file mode 100644 index 592f4055c..000000000 --- a/backend/tests/projects/test_activation_readiness.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Guide readiness and persisted policy-summary custody guards.""" - -from __future__ import annotations - -from collections.abc import Iterator -from copy import deepcopy -from datetime import UTC, datetime -from types import SimpleNamespace -from typing import Any, cast -from uuid import UUID, uuid4 - -import pytest - -from app.core.config import get_settings -from app.core.hashing import canonical_json_hash -from app.modules.projects import service as project_service_module -from app.modules.projects.service import GuideActivationBlocked, ProjectService -from app.modules.projects.post_submit_policy import ( - build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec, -) -from projects.client_fixtures import ( - project_client as project_client, - project_database_env as project_database_env, -) - - -@pytest.fixture(autouse=True) -def clear_settings_after_test() -> Iterator[None]: - """Preserve the originating module's settings-cache cleanup.""" - try: - yield - finally: - get_settings.cache_clear() - - -def _post_submit_policy( - guide: SimpleNamespace, - snapshot: SimpleNamespace, - effective: SimpleNamespace, - pre_submit: SimpleNamespace, -) -> SimpleNamespace: - """Bind post-submit readiness to the same guide and pre-submit lineage.""" - compiled = compile_project_post_submit_checker_spec( - project_id=guide.project_id, guide_version=guide.version, - spec=build_project_post_submit_checker_spec( - project_id=guide.project_id, guide_version=guide.version, - ), - ) - return SimpleNamespace( - id=str(uuid4()), - project_id=guide.project_id, - guide_id=guide.id, - guide_version="v1", - source_snapshot_id=snapshot.id, - source_snapshot_hash=snapshot.bundle_hash, - effective_policy_id=effective.id, - effective_policy_hash=effective.effective_policy_hash, - pre_submit_checker_policy_id=pre_submit.id, - pre_submit_checker_bundle_hash=pre_submit.compiled_bundle_hash, - lifecycle_status="approved", - projection_operation_id=uuid4(), - approval_operation_id=uuid4(), - approved_at=datetime.now(UTC), - policy_body=compiled.policy_body, - policy_hash=compiled.policy_hash, - required_checkers=compiled.required_checkers, - warning_checkers=compiled.warning_checkers, - blocking_severities=list(compiled.blocking_severities), - ) - - -def _review_and_revision_policies() -> tuple[SimpleNamespace, SimpleNamespace]: - """Supply complete review/revision facts without executing either lifecycle.""" - review = SimpleNamespace( - human_review_required=True, - semantics_format="v2", - semantics_status="complete", - policy_hash=f"sha256:{'c' * 64}", - review_preference_window_seconds=60, - review_lease_duration_seconds=60, - max_active_review_leases_per_reviewer=1, - self_review_allowed=False, - reject_policy="allowed", - finding_evidence_requirement="required", - requires_second_review=False, - allowed_decisions=["accept", "needs_revision", "reject"], - minimum_finding_fields=["summary"], - ) - revision = SimpleNamespace( - semantics_status="complete", - policy_hash=f"sha256:{'d' * 64}", - max_revision_rounds=2, - revision_deadline_hours=24, - allowed_resubmission_states=["needs_revision"], - reviewer_reassignment_rule="same_reviewer", - ) - return review, revision - - -def _activation_ready_bundle() -> dict[str, Any]: - """Build one internally consistent activation bundle for fast boundary tests.""" - project_id, guide_id, snapshot_id = (str(uuid4()) for _ in range(3)) - snapshot_hash = f"sha256:{'a' * 64}" - submission_body = {"allowed_extensions": [".zip"]} - submission_hash = canonical_json_hash(submission_body) - effective_body = {"allowed_extensions": [".zip"], "max_bytes": 10} - effective_hash = canonical_json_hash(effective_body) - checker_bundle = {"checks": ["archive_safety"]} - checker_hash = canonical_json_hash(checker_bundle) - guide = SimpleNamespace(id=guide_id, project_id=project_id, version="v1") - snapshot = SimpleNamespace( - id=snapshot_id, - project_id=project_id, - guide_id=guide_id, - guide_version="v1", - bundle_hash=snapshot_hash, - ) - sufficiency = SimpleNamespace( - source_snapshot_id=snapshot_id, - source_snapshot_hash=snapshot_hash, - status="passed", - warnings_acknowledged_by_actor=None, - warnings_acknowledged_at=None, - warnings_acknowledged_by_role=None, - ) - submission = SimpleNamespace( - id=str(uuid4()), - source_snapshot_id=snapshot_id, - source_snapshot_hash=snapshot_hash, - lifecycle_status="approved", - derivation_source="unified_compilation", - policy_body=submission_body, - policy_hash=submission_hash, - approved_by_actor="actor-1", - approved_at=datetime.now(UTC), - approved_by_role="project_manager", - ) - effective = SimpleNamespace( - id=str(uuid4()), - source_snapshot_id=snapshot_id, - source_snapshot_hash=snapshot_hash, - lifecycle_status="approved", - effective_policy=effective_body, - effective_policy_hash=effective_hash, - submission_artifact_policy_id=submission.id, - submission_artifact_policy_hash=submission_hash, - ) - pre_submit = SimpleNamespace( - id=str(uuid4()), - source_snapshot_id=snapshot_id, - source_snapshot_hash=snapshot_hash, - effective_policy_id=effective.id, - effective_policy_hash=effective_hash, - lifecycle_status="compiled", - compiled_bundle=checker_bundle, - compiled_bundle_hash=checker_hash, - ) - post_submit = _post_submit_policy(guide, snapshot, effective, pre_submit) - review, revision = _review_and_revision_policies() - return { - "guide": guide, - "source_snapshot": snapshot, - "sufficiency_report": sufficiency, - "submission_artifact_policy": submission, - "effective_policy": effective, - "pre_submit_checker_policy": pre_submit, - "post_submit_checker_policy": post_submit, - "review_policy": review, - "revision_policy": revision, - # Structural validator delegation only; PostgreSQL proposal tests prove custody. - "post_policy_custody": SimpleNamespace( - approval=SimpleNamespace(operation_id=post_submit.approval_operation_id), - projection=SimpleNamespace(operation_id=post_submit.projection_operation_id), - target=SimpleNamespace(upstream=SimpleNamespace(operation_id=pre_submit.id), policy_hash=post_submit.policy_hash), - ), - "approval_custody": SimpleNamespace( - operation=SimpleNamespace(artifact_policy_id=submission.id, operation_id=pre_submit.id), - effective=effective, pre=pre_submit, - ), - } - - -def _set_activation_fact(bundle: dict[str, Any], fact: str, value: Any) -> None: - target_name, attribute = fact.split(".", 1) - setattr(bundle[target_name], attribute, value) - - -@pytest.mark.parametrize( - ("fact", "value", "message"), - [ - ("source_snapshot.project_id", "other", "snapshot project mismatch"), - ("source_snapshot.guide_id", "other", "snapshot is not current"), - ("sufficiency_report.source_snapshot_id", "other", "stale snapshot"), - ("sufficiency_report.source_snapshot_hash", "other", "snapshot hash mismatch"), - ("sufficiency_report.status", "blocked", "blocking gaps"), - ( - "submission_artifact_policy.lifecycle_status", - "draft", - "approved submission artifact policy", - ), - ("submission_artifact_policy.source_snapshot_id", "other", "bound to a stale snapshot"), - ("submission_artifact_policy.source_snapshot_hash", "other", "snapshot hash mismatch"), - ("submission_artifact_policy.policy_hash", f"sha256:{'e' * 64}", "body hash mismatch"), - ("submission_artifact_policy.approved_by_actor", None, "approval provenance"), - ("submission_artifact_policy.approved_by_role", "submitter", "approver role is invalid"), - ("effective_policy.lifecycle_status", "draft", "effective.*not approved"), - ("effective_policy.source_snapshot_id", "other", "effective.*stale snapshot"), - ("effective_policy.source_snapshot_hash", "other", "effective.*hash mismatch"), - ("effective_policy.effective_policy_hash", f"sha256:{'e' * 64}", "body hash mismatch"), - ("effective_policy.submission_artifact_policy_id", "other", "wrong policy"), - ("effective_policy.submission_artifact_policy_hash", "other", "hash provenance mismatch"), - ("pre_submit_checker_policy.source_snapshot_id", "other", "pre-submit.*stale snapshot"), - ("pre_submit_checker_policy.source_snapshot_hash", "other", "pre-submit.*hash mismatch"), - ("pre_submit_checker_policy.effective_policy_id", "other", "wrong effective policy"), - ("pre_submit_checker_policy.effective_policy_hash", "other", "bundle provenance mismatch"), - ("pre_submit_checker_policy.lifecycle_status", "draft", "compiled project pre-submit"), - ("pre_submit_checker_policy.compiled_bundle_hash", "", "compiled bundle hash is required"), - ("pre_submit_checker_policy.compiled_bundle", {}, "compiled bundle is required"), - ("post_submit_checker_policy.guide_id", "other", "post-submit.*guide mismatch"), - ( - "post_submit_checker_policy.source_snapshot_id", - "other", - "post-submit.*snapshot mismatch", - ), - ("post_submit_checker_policy.effective_policy_id", "other", "wrong effective policy"), - ("post_submit_checker_policy.pre_submit_checker_policy_id", "other", "wrong pre-submit"), - ( - "post_submit_checker_policy.pre_submit_checker_bundle_hash", - "other", - "pre-submit hash mismatch", - ), - ("post_submit_checker_policy.lifecycle_status", "compiled", "approved post-submit"), - ("post_submit_checker_policy.approval_operation_id", None, "approval custody"), - ("post_submit_checker_policy.projection_operation_id", UUID("d8aed408-588e-4214-ae5e-c214e41e2b73"), "approval custody"), - ("review_policy.allowed_decisions", [], "allowed decisions"), - ("review_policy.allowed_decisions", ["maybe"], "invalid decisions"), - ("revision_policy.max_revision_rounds", 0, "revision policy is incomplete"), - ( - "revision_policy.allowed_resubmission_states", - ["accepted"], - "invalid resubmission states", - ), - ], -) -def test_activation_readiness_rejects_broken_chain_fact( - monkeypatch: pytest.MonkeyPatch, - fact: str, - value: Any, - message: str, -) -> None: - bundle = _activation_ready_bundle() - _set_activation_fact(bundle, fact, value) - service = ProjectService(cast(Any, None)) - monkeypatch.setattr( - service, - "_merge_effective_submission_artifact_policy", - lambda _body: deepcopy(bundle["effective_policy"].effective_policy), - ) - monkeypatch.setattr( - project_service_module, - "require_complete_policy", - lambda **_kwargs: None, - ) - - with pytest.raises(GuideActivationBlocked, match=message): - service.validate_activation_ready(**bundle) - - -def test_activation_readiness_accepts_complete_chain_without_payment( - monkeypatch: pytest.MonkeyPatch, -) -> None: - bundle = _activation_ready_bundle() - service = ProjectService(cast(Any, None)) - monkeypatch.setattr( - service, - "_merge_effective_submission_artifact_policy", - lambda _body: deepcopy(bundle["effective_policy"].effective_policy), - ) - monkeypatch.setattr(project_service_module, "require_complete_policy", lambda **_: None) - - service.validate_activation_ready(**bundle) diff --git a/backend/tests/projects/test_locked_policy_custody.py b/backend/tests/projects/test_locked_policy_custody.py index f2e1b61d3..5b304aa88 100644 --- a/backend/tests/projects/test_locked_policy_custody.py +++ b/backend/tests/projects/test_locked_policy_custody.py @@ -204,37 +204,42 @@ async def test_context_read_does_not_allow_activated_proposal_mutations(clean_po ) -async def test_catalogue_rollout_preserves_context_but_blocks_new_activation( +async def test_registry_substitution_blocks_activation_but_frozen_context_never_re_resolves( clean_postgres_database, monkeypatch, ): import pytest from app.modules.projects.api.guide_proposals import GuideProposalError - from app.modules.projects import post_submit_policy - from app.modules.projects.post_policy import custody - from tests.checkers.post_submit.support import altered_catalogue + from app.modules.checkers.external_registry import ExternalCheckerRegistryReader + from tests.checkers.external.support import post_registry_spec from tests.projects.guide_activation.pg_support import activation_case - from tests.authorization.guide_activation.pg_support import ( - activate, activation_state, service, - ) + from tests.authorization.guide_activation.pg_support import activate, activation_state - async with activation_case(clean_postgres_database) as ( - factory, command, actor, *_ - ): - newer = altered_catalogue(index=8, state="disabled") - assert newer.manifest_sha256 != command.target.proposal.post_catalogue_manifest_hash + async with activation_case( + clean_postgres_database, post_submit_registry_spec=post_registry_spec() + ) as (factory, command, actor, *_): before = await activation_state(factory) - with pytest.raises(GuideProposalError): - async with service(factory, actor) as (_, owner, request_id, _): - owner.post_catalogue = newer - await owner.activate(command, actor=actor, request_id=request_id) + read_exact = ExternalCheckerRegistryReader.read_exact + + async def substituted(owner, *args, **kwargs): + current = await read_exact(owner, *args, **kwargs) + return current.model_copy(update={"entry_digest": "sha256:" + "b" * 64}) + + with monkeypatch.context() as patch: + patch.setattr(ExternalCheckerRegistryReader, "read_exact", substituted) + with pytest.raises(GuideProposalError, match="approval_blocked"): + await activate(factory, actor, command) assert await activation_state(factory) == before receipt = await activate(factory, actor, command) async with factory() as session, session.begin(): original = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( frozen_request(receipt) ) - monkeypatch.setattr(post_submit_policy, "current_post_submit_catalogue", lambda: newer) - monkeypatch.setattr(custody, "current_post_submit_catalogue", lambda: newer) + + async def forbidden(*args, **kwargs): + raise AssertionError("locked policy context consulted live registry metadata") + + monkeypatch.setattr(ExternalCheckerRegistryReader, "read_exact", forbidden) + monkeypatch.setattr(ExternalCheckerRegistryReader, "read_identity", forbidden) async with factory() as session, session.begin(): owner = ProjectLockedPolicyRepository(session) assert await owner.lock_locked_policy_context(frozen_request(receipt)) == original diff --git a/backend/tests/projects/test_policy_read_composition.py b/backend/tests/projects/test_policy_read_composition.py index 5d2a9ce58..dbc0eebde 100644 --- a/backend/tests/projects/test_policy_read_composition.py +++ b/backend/tests/projects/test_policy_read_composition.py @@ -41,11 +41,12 @@ async def test_policy_read_binds_exact_target_facts(action: ActionId) -> None: """Bind the exact returned row to all authorization resource facts.""" repository = _PolicyReadRepository() authorization = SimpleNamespace(require=AsyncMock()) - checker = ( - repository.checker - if action is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ else None + checker = repository.checker + target = ( + checker + if action is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else repository.effective ) - target = checker if checker is not None else repository.effective result = await _read(repository, authorization, action) @@ -60,7 +61,11 @@ async def test_policy_read_binds_exact_target_facts(action: ActionId) -> None: "guide_id": repository.guide_id, "guide_version": "v1", "guide_status": "active", - "target_kind": "pre_submit_checker_policy" if checker is not None else "effective_policy", + "target_kind": ( + "pre_submit_checker_policy" + if action is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else "effective_policy" + ), "project_exists": True, "project_status": "active", "guide_exists": True, @@ -70,9 +75,21 @@ async def test_policy_read_binds_exact_target_facts(action: ActionId) -> None: "effective_policy_id": repository.effective.id, "effective_policy_hash": repository.effective.effective_policy_hash, "effective_policy_status": "approved", - "checker_policy_id": checker.id if checker is not None else None, - "checker_policy_status": "compiled" if checker is not None else None, - "checker_bundle_hash": checker.compiled_bundle_hash if checker is not None else None, + "checker_policy_id": ( + checker.id + if action is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else None + ), + "checker_policy_status": ( + "compiled" + if action is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else None + ), + "checker_bundle_hash": ( + checker.compiled_bundle_hash + if action is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ + else None + ), } @@ -89,10 +106,11 @@ async def test_policy_read_binds_exact_digest(action: ActionId) -> None: repository.effective.id, repository.effective.effective_policy_hash, "approved", ], "submission": [repository.submission.id, repository.submission.policy_hash], - "checker": ( - [repository.checker.id, "compiled", repository.checker.compiled_bundle_hash] - if action is ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ else None - ), + "checker": [ + repository.checker.id, + "compiled", + repository.checker.compiled_bundle_hash, + ], }) await _read(repository, authorization, action) @@ -134,6 +152,52 @@ async def test_policy_read_rejects_changed_lineage(row: str, field: str, action: assert context.target_binding_digest is None +@pytest.mark.asyncio +async def test_policy_read_rejects_generic_hash_valid_checker_bundle() -> None: + """A canonical hash cannot turn an obsolete checker body into a current policy.""" + repository = _PolicyReadRepository() + authorization = SimpleNamespace(require=AsyncMock()) + repository.checker.compiled_bundle = {"checkers": ["safe"]} + repository.checker.compiled_bundle_hash = canonical_json_hash( + repository.checker.compiled_bundle + ) + + with pytest.raises(RuntimeError, match="missing policy authorization unexpectedly allowed"): + await _read( + repository, + authorization, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ) + + context = authorization.require.await_args.args[1] + assert context.target_exists is False + assert context.target_binding_digest is None + + +@pytest.mark.asyncio +async def test_effective_policy_read_conceals_chain_missing_current_pre_policy() -> None: + """The effective row is not readable without its current selected PRE policy.""" + repository = _PolicyReadRepository() + repository.checker = None + authorization = SimpleNamespace(require=AsyncMock()) + + with pytest.raises(RuntimeError, match="missing policy authorization unexpectedly allowed"): + await _read( + repository, + authorization, + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ) + + authorization.require.assert_awaited_once() + action, context = authorization.require.await_args.args + assert action is ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ + assert context.target_kind == "effective_policy" + assert context.target_exists is False + assert context.target_binding_digest is None + assert context.checker_policy_id is None + assert context.checker_bundle_hash is None + + @pytest.mark.asyncio @pytest.mark.parametrize("action", POLICY_ACTIONS) async def test_policy_read_propagates_authorizer_exception(action: ActionId) -> None: @@ -172,3 +236,41 @@ def test_activation_readiness_normalizes_hash_valid_malformed_policy_body() -> N effective=repository.effective, pre=repository.checker, ), ) + + +def test_activation_readiness_rejects_hash_valid_obsolete_checker_bundle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = _PolicyReadRepository() + repository.submission.derivation_source = "unified_compilation" + repository.checker.compiled_bundle = {"checkers": ["safe"]} + repository.checker.compiled_bundle_hash = canonical_json_hash( + repository.checker.compiled_bundle + ) + service = ProjectService(cast(Any, None)) + monkeypatch.setattr( + service, + "_merge_effective_submission_artifact_policy", + lambda _body: repository.effective.effective_policy, + ) + + with pytest.raises( + GuideActivationBlocked, + match="external pre-submit checker policy is required", + ): + service.validate_activation_ready( + repository.guide, + repository.snapshot, + repository.sufficiency, + repository.submission, + repository.effective, + repository.checker, + repository.post_submit, + repository.review, + repository.revision, + approval_custody=SimpleNamespace( + operation=SimpleNamespace(artifact_policy_id=repository.submission.id), + effective=repository.effective, + pre=repository.checker, + ), + ) diff --git a/backend/tests/projects/unified_policy_fixtures.py b/backend/tests/projects/unified_policy_fixtures.py index 4bff86314..f4b486ac4 100644 --- a/backend/tests/projects/unified_policy_fixtures.py +++ b/backend/tests/projects/unified_policy_fixtures.py @@ -14,11 +14,6 @@ from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.authorization.api import ActorIdentityFacts, ActorKind from app.modules.authorization.models import AdminRoleGrant -from app.modules.checkers.catalogue import ( - build_pre_submission_checker_catalogue, - project_guide_pre_submission_capabilities, -) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.api import ( ProjectGuideProjectionCommand, ProjectGuideSetupFinalizationCommand, @@ -51,7 +46,7 @@ def downstream_artifact_proposal(): ) -async def create_unified_submission_policy(report_id, snapshot_id, *, proposal=None, post_submit_required_checkers=()): +async def create_unified_submission_policy(report_id, snapshot_id, *, proposal=None, post_submit_registry_spec=None): """Project the actual scripted result, with no manual policy row replacement.""" sessions = db_session.get_session_factory() proposal = proposal or downstream_artifact_proposal() @@ -59,7 +54,7 @@ async def create_unified_submission_policy(report_id, snapshot_id, *, proposal=N report_id, snapshot_id, artifact_proposal=proposal, - post_submit_required_checkers=post_submit_required_checkers, + post_submit_registry_spec=post_submit_registry_spec, ) async with sessions() as session: compilation = ( @@ -125,8 +120,13 @@ async def approve_unified_submission_policy(project_id, guide_id, policy_id, *, request_id=uuid4(), ) assert str(package.target.artifact_policy_id) == policy_id - planner = build_pre_submission_checker_catalogue() async with sessions() as session, session.begin(): + from app.adapters.checkers import ( + external_checker_capability_projection, + external_checker_registry_reader, + ) + + capabilities = await external_checker_capability_projection(session) receipt = await GuideProposalService( session, ProposalAuthority( @@ -135,6 +135,7 @@ async def approve_unified_submission_policy(project_id, guide_id, policy_id, *, UUID(project_id), grant_id, ), + external_checker_registry_reader(session), ).approve( GuideProposalApproval( target=package.target, @@ -146,9 +147,7 @@ async def approve_unified_submission_policy(project_id, guide_id, policy_id, *, actor=actor, request_id=uuid4(), material=guide_document_manifest_port(session), - pre_capabilities=project_guide_pre_submission_capabilities(planner), - post_capabilities=current_post_submit_catalogue(), - planner=planner, + capabilities=capabilities, ) async with sessions() as session: effective = await session.get( @@ -167,6 +166,8 @@ async def create_standalone_unified_policy( artifact_proposal=None, contribution_awards=(), human_review_required=True, + pre_submit_registry_spec=None, + post_submit_registry_spec=None, ): """Arrange complete activated context before a downstream artifact transaction.""" from app.modules.projects.models import PreSubmitCheckerPolicy @@ -180,7 +181,12 @@ async def create_standalone_unified_policy( url = sessions.kw["bind"].url.render_as_string(hide_password=False) async with source_case( - url, namespace=namespace, guide_version=guide_version, artifact_proposal=artifact_proposal + url, + namespace=namespace, + guide_version=guide_version, + artifact_proposal=artifact_proposal, + pre_submit_registry_spec=pre_submit_registry_spec, + post_submit_registry_spec=post_submit_registry_spec, ) 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/retained_material_fixtures.py b/backend/tests/retained_material_fixtures.py index 29183f0ad..91bd63298 100644 --- a/backend/tests/retained_material_fixtures.py +++ b/backend/tests/retained_material_fixtures.py @@ -4,51 +4,81 @@ conformance. Local/MinIO execution has its own focused integration proof. """ -from app.adapters.checkers import submission_evaluation_content - from contextlib import asynccontextmanager from io import BytesIO from pathlib import Path from tempfile import TemporaryDirectory from uuid import UUID +from weakref import WeakKeyDictionary from zipfile import ZipFile from sqlalchemy import select -from app.adapters.artifacts import CheckerPhaseService +from app.adapters.auth import selected_pre_submit_authority +from app.adapters.checkers import ( + SelectedPreSubmitCheckerExecutionAdapter, + selected_pre_submit_coordinator, +) from app.adapters.tasks import task_submission_context_port from app.core.identifiers import new_record_id from app.interfaces.artifacts import ( - ArtifactObjectHead, ArtifactPutObservation, ArtifactPutResult, artifact_provider_object_ref, + ArtifactObjectHead, + ArtifactPutObservation, + ArtifactPutResult, + artifact_provider_object_ref, ) from app.interfaces.external_services import ExternalServiceAdapterIdentity from app.modules.artifacts.api import SubmissionBundlePreparationRequest -from app.modules.artifacts.authorization import PreparedSubmissionBundlePreparationAuthorization -from app.modules.artifacts.models import ArtifactStorageNamespace, ArtifactVerificationJob, SubmissionBundleAdmission +from app.modules.artifacts.authorization import ( + PreparedPreSubmitMaterializationAuthorization, + PreparedSubmissionBundlePreparationAuthorization, +) +from app.modules.artifacts.models import ( + ArtifactStorageNamespace, + ArtifactVerificationJob, + SubmissionBundleAdmission, +) from app.modules.artifacts.preparation import ArtifactPreparationService, ArtifactScratchManager -from app.modules.artifacts.service import ArtifactAdmissionService, ArtifactStorageNamespaceSpec, ArtifactStorageOrchestrator +from app.modules.artifacts.service import ( + ArtifactAdmissionService, + ArtifactStorageNamespaceSpec, + ArtifactStorageOrchestrator, +) from app.modules.artifacts.submission_admission import ( - PreparedSubmissionBundlePreparationCommand, SubmissionBundleDurablePutService, + PreparedSubmissionBundlePreparationCommand, + SubmissionBundleDurablePutService, SubmissionBundlePreparationRuntime, ) -from app.modules.artifacts.submission_archive import SubmissionArchiveInspector, SubmissionArchiveLimits +from app.modules.artifacts.selected_pre_submit_execution import SelectedPreSubmitExecutionService +from app.modules.artifacts.submission_archive import ( + SubmissionArchiveInspector, + SubmissionArchiveLimits, +) +from app.modules.artifacts.submission_materialization import PreparedBundleMaterializationService from app.modules.authorization.api import ActorIdentityFacts, ActorKind -from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue +from app.modules.actors.api import ServiceIdentity from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository from app.modules.projects.models import EffectiveProjectSubmissionArtifactPolicy -from tests.checkers.execution.support import forbidden_post_submission -from tests.pre_submit_test_helpers import evidence_workflow from tests.test_artifact_admission import _AllowArtifactAuthority, _context, _settings from tests.test_checker_materialization import _limits -from tests.test_default_pre_submit_execution import _AllowAuthority, _bytes +from tests.checkers.execution.support import provision_post_submit_service + + +async def _bytes(value: bytes): + yield value + + +class _ForbiddenExternalExecution: + async def execute(self, *_args, **_kwargs): + raise AssertionError("empty selected policy invoked an external image") class MemoryStore: """Implement the existing byte port; all database work stays in ART owners.""" - def __init__(self, namespace): + def __init__(self, namespace, *, objects=None): self.identity = ExternalServiceAdapterIdentity("artifact_store", namespace.adapter) - self.objects = {} + self.objects = {} if objects is None else objects async def put(self, source): ref = artifact_provider_object_ref(source.commitment) @@ -67,7 +97,7 @@ async def open(self, ref, byte_range=None): data = self.objects[ref] if byte_range is not None: end = None if byte_range.length is None else byte_range.offset + byte_range.length - data = data[byte_range.offset:end] + data = data[byte_range.offset : end] yield data async def head(self, ref): @@ -75,36 +105,144 @@ async def head(self, ref): return ArtifactObjectHead(ref, data is not None, len(data) if data is not None else None) -async def retained_admission(factory, task, assignment, link, packet, predecessor_id): - """Prepare/check/publish/verify actual ZIP bytes for this existing assignment.""" - async with factory() as session: - persisted = await session.get(ArtifactStorageNamespace, "primary") - namespace = ArtifactStorageNamespaceSpec( - persisted.backend, persisted.adapter, persisted.provider_profile, - persisted.namespace_descriptor, persisted.namespace_fingerprint, - ) - policy = (await session.get( - EffectiveProjectSubmissionArtifactPolicy, - task.locked_effective_project_submission_artifact_policy_id, - )).effective_policy +_RETAINED_OBJECTS = WeakKeyDictionary() + + +def retained_archive_store(factory, namespace): + """Keep immutable fixture bytes until their owning test factory is disposed.""" + namespaces = _RETAINED_OBJECTS.setdefault(factory, {}) + objects = namespaces.setdefault(namespace.namespace_fingerprint, {}) + return MemoryStore(namespace, objects=objects) + + +def _submission_request(task, assignment, link, packet, predecessor_id, policy): content = BytesIO() paths = {entry["path"] for entry in policy["required_artifacts"] if entry.get("required", True)} - paths.update(entry.get("path") or "evidence/" + entry["key"] - for entry in policy["required_evidence"] if entry.get("required", True)) + paths.update( + entry.get("path") or "evidence/" + entry["key"] + for entry in policy["required_evidence"] + if entry.get("required", True) + ) with ZipFile(content, "w") as archive: for path in sorted(paths): archive.writestr(path, f"{task.id}:{predecessor_id}:{packet.summary}\n") - store = MemoryStore(namespace) context = _context(actor_profile_id=UUID(task.assigned_to), identity_link_id=UUID(link.id)) request = SubmissionBundlePreparationRequest( - actor=ActorIdentityFacts(context.actor_profile_id, context.identity_link_id, ActorKind.HUMAN), - request_id=context.request_id, correlation_id=context.correlation_id, - task_id=UUID(task.id), assignment_id=UUID(assignment.id), + actor=ActorIdentityFacts( + context.actor_profile_id, context.identity_link_id, ActorKind.HUMAN + ), + request_id=context.request_id, + correlation_id=context.correlation_id, + task_id=UUID(task.id), + assignment_id=UUID(assignment.id), predecessor_submission_id=UUID(predecessor_id) if predecessor_id else None, - idempotency_key=new_record_id(), summary=packet.summary, + idempotency_key=new_record_id(), + summary=packet.summary, contributor_attestation=packet.worker_attestation, - media_type="application/zip", byte_source=_bytes(content.getvalue()), + media_type="application/zip", + byte_source=_bytes(content.getvalue()), ) + return context, request + + +def _runtime_factory( + *, + session, + settings, + namespace, + authority, + materialization_authority, + preparation, + inspector, + materialization, + storage, + tasks, + projects, + coordinator, +): + @asynccontextmanager + async def runtime(): + try: + yield SubmissionBundlePreparationRuntime( + preparation=preparation, + inspector=inspector, + materialization=materialization, + durable_put=SubmissionBundleDurablePutService( + session=session, + admission=ArtifactAdmissionService(session, settings, namespace), + storage=storage, + authorization=authority, + task_contexts=tasks, + project_contexts=projects, + ), + selected_coordinator=coordinator, + selected_execution=SelectedPreSubmitExecutionService( + session=session, + coordinator=coordinator, + authority=selected_pre_submit_authority(session), + materialization=materialization, + ), + storage_scheme="s3", + artifact_deadline_ms=180_000, + ) + finally: + materialization_authority.close() + + return runtime + + +async def _verify_admission(factory, store, namespace, settings, result): + async with factory() as session: + job_id = await session.scalar( + select(ArtifactVerificationJob.id).where( + ArtifactVerificationJob.originating_put_attempt_id == str(result.put_attempt_id), + ) + ) + assert job_id is not None, result + await session.rollback() + orchestrator = ArtifactStorageOrchestrator( + session, store, namespace, settings, _AllowArtifactAuthority() + ) + assert await orchestrator.verify_object(UUID(job_id)) == "verified" + admission = await session.scalar( + select(SubmissionBundleAdmission).where( + SubmissionBundleAdmission.put_attempt_id == str(result.put_attempt_id), + ) + ) + assert admission.status == "ready" + return admission.id + + +async def retained_admission( + factory, + task, + assignment, + link, + packet, + predecessor_id, + *, + external_execution=None, +): + """Prepare/check/publish/verify actual ZIP bytes for this existing assignment.""" + await provision_post_submit_service(factory, ServiceIdentity.ARTIFACT_MATERIALIZER) + await provision_post_submit_service(factory, ServiceIdentity.CHECKER_PRE_SUBMIT) + async with factory() as session: + persisted = await session.get(ArtifactStorageNamespace, "primary") + namespace = ArtifactStorageNamespaceSpec( + persisted.backend, + persisted.adapter, + persisted.provider_profile, + persisted.namespace_descriptor, + persisted.namespace_fingerprint, + ) + policy = ( + await session.get( + EffectiveProjectSubmissionArtifactPolicy, + task.locked_effective_project_submission_artifact_policy_id, + ) + ).effective_policy + context, request = _submission_request(task, assignment, link, packet, predecessor_id, policy) + store = retained_archive_store(factory, namespace) with TemporaryDirectory(prefix="retained-material-") as directory: settings = _settings(Path(directory), maximum_bytes=1024 * 1024) # This fixture traverses multiple real database transactions. Its scratch @@ -116,46 +254,50 @@ async def retained_admission(factory, task, assignment, link, packet, predecesso try: async with factory() as session: authority = PreparedSubmissionBundlePreparationAuthorization(session, context) + materialization_authority = PreparedPreSubmitMaterializationAuthorization( + session, + request_id=request.request_id, + correlation_id=request.correlation_id, + ) tasks = task_submission_context_port(session) projects = ProjectLockedPolicyRepository(session) preparation = ArtifactPreparationService(manager) inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) - catalogue = build_pre_submission_checker_catalogue() - evidence = evidence_workflow( - session=session, preparation=preparation, inspector=inspector, catalogue=catalogue, - materialization_authorization=_AllowAuthority(), preparation_authorization=authority, + materialization = PreparedBundleMaterializationService( + session=session, + authorization=materialization_authority, + preparation=preparation, + checker_execution=SelectedPreSubmitCheckerExecutionAdapter( + archive_inspector=inspector, + external_execution=external_execution or _ForbiddenExternalExecution(), + ), + storage_scheme="s3", ) - storage = ArtifactStorageOrchestrator(session, store, namespace, settings, _AllowArtifactAuthority()) - - @asynccontextmanager - async def runtime(): - yield SubmissionBundlePreparationRuntime( - preparation, inspector, catalogue, evidence._materialization, evidence, - CheckerPhaseService(pre_submission=evidence, post_submission=forbidden_post_submission()), - SubmissionBundleDurablePutService( - session=session, admission=ArtifactAdmissionService(session, settings, namespace), - storage=storage, authorization=authority, task_contexts=tasks, project_contexts=projects, - ), - submission_evaluation_content, - ) + storage = ArtifactStorageOrchestrator( + session, store, namespace, settings, _AllowArtifactAuthority() + ) + coordinator = selected_pre_submit_coordinator(session) result = await PreparedSubmissionBundlePreparationCommand( - session=session, authority=authority, task_contexts=tasks, - project_contexts=projects, runtime_factory=runtime, + session=session, + authority=authority, + task_contexts=tasks, + project_contexts=projects, + runtime_factory=_runtime_factory( + session=session, + settings=settings, + namespace=namespace, + authority=authority, + materialization_authority=materialization_authority, + preparation=preparation, + inspector=inspector, + materialization=materialization, + storage=storage, + tasks=tasks, + projects=projects, + coordinator=coordinator, + ), ).prepare(request) - async with factory() as session: - job_id = await session.scalar(select(ArtifactVerificationJob.id).where( - ArtifactVerificationJob.originating_put_attempt_id == str(result.put_attempt_id), - )) - assert job_id is not None, result - await session.rollback() - assert await ArtifactStorageOrchestrator( - session, store, namespace, settings, _AllowArtifactAuthority(), - ).verify_object(UUID(job_id)) == "verified" - admission = await session.scalar(select(SubmissionBundleAdmission).where( - SubmissionBundleAdmission.put_attempt_id == str(result.put_attempt_id), - )) - assert admission.status == "ready" - return admission.id + return await _verify_admission(factory, store, namespace, settings, result) finally: manager.close() diff --git a/backend/tests/reviews/acceptance/historical_support.py b/backend/tests/reviews/acceptance/historical_support.py index 89669bed7..dc66b2765 100644 --- a/backend/tests/reviews/acceptance/historical_support.py +++ b/backend/tests/reviews/acceptance/historical_support.py @@ -1,42 +1,18 @@ -"""Pre-0029 acceptance storage for migration preservation only.""" +"""Literal predecessor acceptance storage, never current acceptance authority.""" from contextlib import asynccontextmanager -from uuid import UUID - from sqlalchemy import insert, text from app.core.identifiers import new_record_id from app.modules.reviews.acceptance.models import FinalAcceptance -from pydantic import BaseModel -from typing import Literal -from tests.reviews.decision.support import finding, insert_review, review_source - - -class HistoricalAcceptanceInput(BaseModel): - id: UUID - project_id: UUID - task_id: UUID - submission_id: UUID - acceptance_source: Literal["human_review"] - source_review_id: UUID - source_routing_manifest_id: None - accepted_submitter_id: UUID - recorded_by: UUID - policy_context_ref: UUID +from tests.migration_ancestor_rows import archived_review_ancestors, seed_review_row @asynccontextmanager async def historical_acceptance_source(tmp_path, database_url, *, decision="accept", **options): - async with review_source(tmp_path, database_url, **options) as h: - h.review = h.review.model_copy( - update={ - "decision": decision, - "findings": (finding(),) if decision == "needs_revision" else (), - } - ) - async with h.factory() as session: - await insert_review(session, h.review) - await session.commit() + async with archived_review_ancestors(tmp_path, database_url, decision=decision, **options) as h: + async with h.factory() as session, session.begin(): + await seed_review_row(session, h.review) await attach_historical_acceptance(h) yield h @@ -44,38 +20,23 @@ async def historical_acceptance_source(tmp_path, database_url, *, decision="acce async def attach_historical_acceptance(h): async with h.factory() as session: revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) - assert 13 <= int(revision[:4]) <= 28, "historical acceptance is not current authority" - contributor = await session.scalar( - text("SELECT contributor_id FROM public.submissions WHERE id=:id"), - {"id": h.review.submission_id}, - ) - h.acceptance = HistoricalAcceptanceInput( - id=new_record_id(), - project_id=h.review.project_id, - task_id=h.review.task_id, - submission_id=h.review.submission_id, - acceptance_source="human_review", - source_review_id=h.review.id, - source_routing_manifest_id=None, - accepted_submitter_id=UUID(str(contributor)), - recorded_by=h.review.reviewer_id, - policy_context_ref=h.review.locked_review_policy_id, - ) + assert 13 <= int(revision[:4]) <= 28 + contributor = await session.scalar(text( + "SELECT contributor_id FROM public.submissions WHERE id=:id" + ), {"id": h.review["submission_id"]}) + h.acceptance = { + "id": new_record_id(), "project_id": h.review["project_id"], "task_id": h.review["task_id"], + "submission_id": h.review["submission_id"], "acceptance_source": "human_review", + "source_review_id": h.review["id"], "source_routing_manifest_id": None, + "accepted_submitter_id": contributor, "recorded_by": h.review["reviewer_id"], + "policy_context_ref": h.review["locked_review_policy_id"], + } async def insert_historical_acceptance(session, source, **overrides): revision = await session.scalar(text("SELECT version_num FROM public.alembic_version")) - assert 14 <= int(revision[:4]) <= 28, "historical acceptance cannot write the current schema" - values = source.model_dump() - values.update(overrides) - for field in ( - "project_id", - "task_id", - "submission_id", - "accepted_submitter_id", - "recorded_by", - "policy_context_ref", - ): - if values[field] is not None: - values[field] = str(values[field]) + assert 14 <= int(revision[:4]) <= 28 + values = source | overrides + for field in ("project_id", "task_id", "submission_id", "accepted_submitter_id", "recorded_by", "policy_context_ref"): + values[field] = str(values[field]) await session.execute(insert(FinalAcceptance).values(**values)) diff --git a/backend/tests/reviews/acceptance/test_migration.py b/backend/tests/reviews/acceptance/test_migration.py index 256bd4e6a..db96ba48c 100644 --- a/backend/tests/reviews/acceptance/test_migration.py +++ b/backend/tests/reviews/acceptance/test_migration.py @@ -6,9 +6,7 @@ import pytest from alembic import command -from tests.checkers.execution.historical_execution import historical_completed_source from app.db import session as db_session -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config from tests.reviews.acceptance.historical_support import historical_acceptance_source from tests.reviews.decision.test_migration import snapshot as source_snapshot @@ -35,11 +33,9 @@ async def test_acceptance_upgrade_preserves_owners(tmp_path, isolated_database_e finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0013_review_source") - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_acceptance_source( - tmp_path, isolated_database_env, completed_source_factory=historical_completed_source + tmp_path, isolated_database_env ): - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) connection = await asyncpg.connect(url) try: before = await snapshot(connection) diff --git a/backend/tests/reviews/decision/support.py b/backend/tests/reviews/decision/support.py index ec009ca34..173d3e335 100644 --- a/backend/tests/reviews/decision/support.py +++ b/backend/tests/reviews/decision/support.py @@ -32,11 +32,14 @@ from tests.tasks.post_submit_routing.support import next_request, source_values from tests.tasks.submission_lineage_support import _verified_admission from tests.test_artifact_admission import _context -from tests.test_default_pre_submit_execution import _bytes +from tests.archive_fixtures import byte_source as _bytes @asynccontextmanager async def review_source(tmp_path, database_url, **options): + from tests.checkers.external.support import post_registry_spec + + options.setdefault("post_submit_registry_spec", post_registry_spec()) async with packet_source(tmp_path, database_url, **options) as h: await attach_review_source(h) yield h @@ -180,7 +183,7 @@ async def _route_replacement_cause(h): from app.adapters.tasks import evaluation_completion_handler from app.modules.outbox.api import HandlerOutcome from tests.authorization.post_submit_routing.support import provision_router - from tests.checkers.execution.completion_fixture import controlled_registry + from tests.checkers.execution.support import ControlledSelectedExecution from tests.tasks.post_submit_routing.outcome_support import invoked_completion async with h.factory() as session: @@ -200,7 +203,7 @@ async def _route_replacement_cause(h): await next_request(failure) await reserve(failure) failure.result = await live_executor( - failure, registry=controlled_registry("needs_revision") + failure, selected_execution=ControlledSelectedExecution(verdict="failed") ).evaluate_post_submission(failure.request) async with h.factory() as session: run = await session.get(CheckerRun, str(failure.result.attempt_id)) @@ -277,8 +280,8 @@ async def _admit_successor(h): assignment_id=h.request.assignment_id, predecessor_submission_id=h.request.submission_id, idempotency_key=new_record_id(), - summary=h.request.structural_input.summary, - contributor_attestation=h.request.structural_input.worker_attestation, + summary=h.creation_request.summary, + contributor_attestation=h.creation_request.contributor_attestation, media_type="application/zip", byte_source=_bytes(revised_data), ) diff --git a/backend/tests/reviews/decision/test_migration.py b/backend/tests/reviews/decision/test_migration.py index 9890418a5..2bc72ec68 100644 --- a/backend/tests/reviews/decision/test_migration.py +++ b/backend/tests/reviews/decision/test_migration.py @@ -6,11 +6,9 @@ import pytest from alembic import command -from tests.checkers.execution.historical_execution import historical_completed_source +from tests.migration_ancestor_rows import archived_review_ancestors from app.db import session as db_session -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config -from tests.reviews.decision.support import review_source pytestmark = pytest.mark.postgres_schema_contract @@ -44,11 +42,9 @@ async def test_review_upgrade_preserves_owners(tmp_path, isolated_database_env, finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0012_review_packet") - original_columns = await add_current_art_seed_column(isolated_database_env) - async with review_source( - tmp_path, isolated_database_env, completed_source_factory=historical_completed_source + async with archived_review_ancestors( + tmp_path, isolated_database_env ): - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) connection = await asyncpg.connect(url) try: before = await snapshot(connection) diff --git a/backend/tests/reviews/lifecycle/test_migration.py b/backend/tests/reviews/lifecycle/test_migration.py index 0430253c9..aa63b3654 100644 --- a/backend/tests/reviews/lifecycle/test_migration.py +++ b/backend/tests/reviews/lifecycle/test_migration.py @@ -9,7 +9,6 @@ import pytest from alembic import command -from tests.checkers.execution.historical_execution import historical_completed_source from app.db import session as db_session from tests.contributions.records.support import ( award_values, @@ -17,7 +16,6 @@ insert_record, ) from tests.contributions.records.test_migration import snapshot as source_snapshot -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config pytestmark = pytest.mark.postgres_schema_contract @@ -42,14 +40,11 @@ async def test_lifecycle_upgrade_preserves_sources(tmp_path, isolated_database_e finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0015_contribution_awards") - original_columns = await add_current_art_seed_column(isolated_database_env) async with historical_contribution_source( tmp_path, isolated_database_env, paid=True, - completed_source_factory=historical_completed_source, ) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) async with h.factory() as session: for record in (h.reviewer_record, h.submitter_record): await insert_record(session, record) @@ -131,7 +126,6 @@ async def test_transition_upgrade_preserves_genesis_and_retained_sources( tmp_path, isolated_database_env, paid=True, - completed_source_factory=historical_completed_source, ): await asyncio.to_thread(command.upgrade, _config(), "0027_markdown_guide_media") connection = await asyncpg.connect(url) diff --git a/backend/tests/reviews/packet/support.py b/backend/tests/reviews/packet/support.py index 5de8cb8d3..e45a6f8f6 100644 --- a/backend/tests/reviews/packet/support.py +++ b/backend/tests/reviews/packet/support.py @@ -37,10 +37,9 @@ async def packet_source( database_url, *, lease_duration=timedelta(days=1), - completed_source_factory=completed_source, **source_options, ): - async with completed_source_factory(tmp_path, database_url, **source_options) as h: + async with completed_source(tmp_path, database_url, **source_options) as h: await prepare_packet(h, lease_duration=lease_duration) yield h diff --git a/backend/tests/reviews/packet/test_migration.py b/backend/tests/reviews/packet/test_migration.py index b767a2d0b..45e73b58c 100644 --- a/backend/tests/reviews/packet/test_migration.py +++ b/backend/tests/reviews/packet/test_migration.py @@ -7,12 +7,10 @@ import pytest from alembic import command -from tests.checkers.execution.historical_execution import historical_completed_source +from tests.migration_ancestor_rows import archived_packet_ancestors from app.db import session as db_session -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config from tests.migration_fixtures import current_schema_revision -from tests.reviews.packet.support import packet_source pytestmark = pytest.mark.postgres_schema_contract @@ -49,11 +47,9 @@ async def test_packet_upgrade_preserves_existing_owners( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0011_task_routing_source") - original_columns = await add_current_art_seed_column(isolated_database_env) - async with packet_source( - tmp_path, isolated_database_env, completed_source_factory=historical_completed_source + async with archived_packet_ancestors( + tmp_path, isolated_database_env ): - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) connection = await asyncpg.connect(url) try: before = await snapshot(connection) diff --git a/backend/tests/selected_policy_fixtures.py b/backend/tests/selected_policy_fixtures.py new file mode 100644 index 000000000..3fa3983a1 --- /dev/null +++ b/backend/tests/selected_policy_fixtures.py @@ -0,0 +1,64 @@ +"""Approved selected checker policy facts for current ART and TASK journeys.""" + +import json +from types import SimpleNamespace + +from sqlalchemy import select + + +async def approved_selected_policy_fixture( + factory, + namespace, + *, + guide_version, + contribution_awards=(), + human_review_required=True, + pre_submit_registry_spec=None, + post_submit_registry_spec=None, +): + """Build an activated guide backed only by immutable external policy custody.""" + from app.interfaces.project_agents import SubmissionArtifactPolicyProposal + from app.modules.projects.models import ProjectGuide, PostSubmitCheckerPolicy + from tests.projects.unified_policy_fixtures import create_standalone_unified_policy + + values, effective, pre = await create_standalone_unified_policy( + factory, + namespace, + guide_version=guide_version, + contribution_awards=contribution_awards, + human_review_required=human_review_required, + artifact_proposal=SubmissionArtifactPolicyProposal( + maximum_file_size_bytes=1_000_000, + maximum_package_size_bytes=5_000_000, + required_artifacts=("task.toml",), + required_evidence=("results",), + attestation_terms=("rights_confirmed",), + ), + pre_submit_registry_spec=pre_submit_registry_spec, + post_submit_registry_spec=post_submit_registry_spec, + ) + async with factory() as session: + guide = await session.get(ProjectGuide, str(values["guide"])) + post = await session.scalar( + select(PostSubmitCheckerPolicy).where( + PostSubmitCheckerPolicy.effective_policy_id == effective["id"] + ) + ) + assert post is not None + assert pre.compiled_bundle.get("schema_version") == "external_pre_submit_policy.v1" + assert post.policy_body.get("schema_version") == "external_post_submit_policy.v1" + owner = SimpleNamespace(lineage=SimpleNamespace(project_id=values["project"])) + return owner, { + "post_policy": post.id, + "post_policy_hash": post.policy_hash, + "post_policy_body": json.dumps(post.policy_body), + "submission_policy": effective["submission_artifact_policy_id"], + "review_policy": guide.selected_review_policy_id, + "review_policy_hash": guide.selected_review_policy_hash, + "revision_policy": guide.selected_revision_policy_id, + "revision_policy_hash": guide.selected_revision_policy_hash, + "guide_version": guide_version, + "attestation_terms": effective["effective_policy"]["attestation_terms"], + "evidence_path": "evidence/" + + effective["effective_policy"]["required_evidence"][0]["key"], + } diff --git a/backend/tests/selected_predecessor_fixtures.py b/backend/tests/selected_predecessor_fixtures.py new file mode 100644 index 000000000..61da9ecaf --- /dev/null +++ b/backend/tests/selected_predecessor_fixtures.py @@ -0,0 +1,174 @@ +"""Selected PRE replacement evidence fixtures; no alternate execution owner.""" + +from dataclasses import replace +from io import BytesIO +from uuid import UUID +import json +from zipfile import ZipFile + +from sqlalchemy import select, text + +from app.core.identifiers import new_record_id +from app.modules.artifacts.selected_pre_submit_admission import ( + selected_pre_submit_attempt_request_digest, +) +from app.modules.checkers.models import SelectedPreSubmitRun +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitReservationFacts, + SelectedPreSubmitTerminalFacts, +) + +_PREPARATION_TABLES = ( + "selected_pre_submit_runs", + "pre_submit_execution_attempts", + "pre_submit_evidence_sets", + "pre_submit_evidence_results", + "submission_bundle_durable_intents", + "artifact_put_attempts", + "artifact_put_attempt_charges", + "artifact_contents", + "artifact_replicas", + "artifact_operation_receipts", + "artifact_put_observation_receipts", + "artifact_verification_jobs", + "artifact_verification_receipts", + "submission_bundle_admissions", +) + + +async def _snapshot(h) -> tuple[tuple[str, int], ...]: + async with h.factory() as session: + rows = [] + for table in _PREPARATION_TABLES: + count = await session.scalar(text(f"SELECT pg_catalog.count(*) FROM public.{table}")) + assert count is not None + rows.append((table, int(count))) + return tuple(rows) + + +def _secret_archive(data: bytes) -> bytes: + """Keep the real changed packet and introduce one known secret signature.""" + output = BytesIO(data) + with ZipFile(output, "a") as archive: + archive.writestr("probe.txt", b"-----BEGIN OPENSSH PRIVATE KEY-----\nprobe\n") + return output.getvalue() + + +async def _assert_default_failure(h, request, failure, before, code: str) -> None: + """Allow only the exact terminal selected run while ART remains untouched.""" + assert failure.facts.eligible is False + failed = tuple( + check for check in failure.facts.workstream_default.checks if check.status == "failed" + ) + assert tuple(check.failure_code for check in failed) == (code,) + assert failure.facts.external_members == () + after = dict(await _snapshot(h)) + expected = dict(before) + expected["selected_pre_submit_runs"] += 1 + assert after == expected + async with h.factory() as session: + run = ( + await session.scalars( + select(SelectedPreSubmitRun).where( + SelectedPreSubmitRun.actor_profile_id == str(request.actor.actor_profile_id), + SelectedPreSubmitRun.idempotency_key == str(request.idempotency_key), + ) + ) + ).one() + terminal = SelectedPreSubmitTerminalFacts.from_canonical_body(run.terminal_json) + reservation = terminal.lease.reservation + assert run.status == terminal.outcome == "completed" + assert run.eligible is False and terminal.execution == failure.facts + assert terminal.terminal_digest == run.terminal_digest + assert reservation.request_digest == run.request_digest + assert reservation.task_id == request.task_id + assert reservation.assignment_id == request.assignment_id + assert reservation.actor_profile_id == request.actor.actor_profile_id + assert ( + reservation.admission_lineage.predecessor_submission_id + == request.predecessor_submission_id + ) + assert ( + await session.scalar( + text(""" + SELECT public.selected_pre_submit_receipt_valid(r,'execute',false) + AND public.selected_pre_submit_receipt_valid(r,'finalize',false) + FROM public.selected_pre_submit_runs r WHERE id=:id + """), + {"id": run.id}, + ) + is True + ) + + +async def _queued_selected_body(h, admission_id, *, cause=None) -> dict: + """Build a valid fresh reservation from one actual selected admission.""" + async with h.factory() as session: + body = await session.scalar( + text(""" + SELECT to_jsonb(r) FROM public.selected_pre_submit_runs r + JOIN public.submission_bundle_admissions a ON a.selected_pre_submit_run_id=r.id + WHERE a.id=:id + """), + {"id": admission_id}, + ) + assert body is not None and body["status"] == "completed" and body["eligible"] is True + facts = SelectedPreSubmitReservationFacts.from_canonical_body(body["request_json"]) + lineage = facts.admission_lineage + assert lineage is not None + key = new_record_id() + facts = replace( + facts, + attempt_id=new_record_id(), + idempotency_key=key, + prepared_generation_id=new_record_id(), + admission_lineage=( + replace(lineage, checker_remediation_manifest_id=UUID(str(cause))) + if cause is not None + else lineage + ), + attempt_request_digest=selected_pre_submit_attempt_request_digest( + actor_profile_id=facts.actor_profile_id, + identity_link_id=facts.identity_link_id, + task_id=facts.task_id, + assignment_id=facts.assignment_id, + predecessor_submission_id=lineage.predecessor_submission_id, + idempotency_key=key, + archive_sha256=facts.archive_sha256, + archive_byte_count=facts.archive_byte_count, + packet_sha256=lineage.packet_sha256, + ), + ) + return body | { + "id": str(facts.attempt_id), + "idempotency_key": str(key), + "prepared_generation_id": str(facts.prepared_generation_id), + "request_json": facts.canonical_body(), + "request_digest": facts.request_digest, + "status": "queued", + "lease_id": None, + "lease_generation": 0, + "lease_expires_at": None, + "execute_evidence_id": None, + "finalize_evidence_id": None, + "terminal_json": None, + "terminal_digest": None, + "eligible": None, + "started_at": None, + "completed_at": None, + } + + +async def _insert_selected_reservation(h, body) -> None: + """Exercise installed native INSERT guards without disabling any custody.""" + async with h.factory.begin() as session: + await session.execute( + text(""" + INSERT INTO public.selected_pre_submit_runs + SELECT (pg_catalog.jsonb_populate_record( + NULL::public.selected_pre_submit_runs, CAST(:body AS jsonb) + || pg_catalog.jsonb_build_object('queued_at',clock_timestamp()) + )).* + """), + {"body": json.dumps(body)}, + ) diff --git a/backend/tests/submission_capacity_fixtures.py b/backend/tests/submission_capacity_fixtures.py index 613167ea2..b256c8d5c 100644 --- a/backend/tests/submission_capacity_fixtures.py +++ b/backend/tests/submission_capacity_fixtures.py @@ -8,23 +8,28 @@ from app.core.identifiers import new_record_id from app.modules.artifacts.api import SubmissionBundlePreparationRequest from app.modules.authorization.api import ActorIdentityFacts, ActorKind -from tests.pre_submit_test_helpers import approved_pre_submit_fixture +from tests.selected_policy_fixtures import approved_selected_policy_fixture from tests.submission_preparation_auth_helpers import install_submitter_grant from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test from tests.tasks.submission_lineage_support import _seed_services from tests.test_artifact_admission import _settings, _namespace, _local_store, _context, _seed_human_actor -from tests.test_default_pre_submit_execution import _archive, _bytes +from tests.archive_fixtures import byte_source as _bytes, submission_archive as _archive @asynccontextmanager async def capacity_fixture(database_url, tmp_path): engine = create_async_engine(database_url) factory = async_sessionmaker(engine, expire_on_commit=False) - settings = _settings(tmp_path, maximum_bytes=1024 * 1024) + settings = _settings(tmp_path, maximum_bytes=1024 * 1024).model_copy( + update={"external_checker_service_socket": tmp_path / "checker.sock", + "external_checker_material_root": tmp_path / "scratch"} + ) namespace = _namespace(settings) bootstrap, store = _local_store(settings, namespace) try: - plan, policy = await approved_pre_submit_fixture(factory, namespace, guide_version="v1") + plan, policy = await approved_selected_policy_fixture( + factory, namespace, guide_version="v1" + ) context = _context() await _seed_services(factory) task_id, assignment_id = new_record_id(), new_record_id() diff --git a/backend/tests/submission_fixtures.py b/backend/tests/submission_fixtures.py index 78a54ebf3..55c1d6b98 100644 --- a/backend/tests/submission_fixtures.py +++ b/backend/tests/submission_fixtures.py @@ -2,7 +2,8 @@ These fixtures use ART preparation, verification and consumption owners with a scripted provider, then seed retained Submission projections with locked -lineage. They do not expose public intake or claim live post-submit authority. +lineage. CHECKERS reads these bytes with real phase authority and controlled +external-image outcomes; this is not a deployment/provider conformance proof. """ from uuid import UUID @@ -101,11 +102,11 @@ def retained_packet(**values): return submission_id -async def seed_retained_checker_run(submission_id: str, *, failures=(), state="completed", generation=1) -> str: - """Seed closed history through real CHECKERS custody, with controlled phase authority.""" +async def seed_retained_checker_run(submission_id: str, *, verdict="passed", state="completed", generation=1) -> str: + """Execute closed history with real phase custody and controlled image verdicts.""" from tests.checkers.execution.storage_fixture import seed_storage_run return await seed_storage_run(db_session.get_session_factory(), submission_id, - failures=failures, state=state, generation=generation) + verdict=verdict, state=state, generation=generation) async def route_retained_checker_remediation(submission_id: str, *, generation: int = 1) -> str: @@ -123,7 +124,7 @@ async def route_retained_checker_remediation(submission_id: str, *, generation: factory = db_session.get_session_factory() run_id = await seed_retained_checker_run( - submission_id, failures=("check_submission_packet",), generation=generation, + submission_id, verdict="failed", generation=generation, ) async with factory() as session: run = await session.get(CheckerRun, run_id) diff --git a/backend/tests/tasks/evaluation_delivery/test_completion.py b/backend/tests/tasks/evaluation_delivery/test_completion.py index 33fa9e643..0d7f62a1e 100644 --- a/backend/tests/tasks/evaluation_delivery/test_completion.py +++ b/backend/tests/tasks/evaluation_delivery/test_completion.py @@ -8,14 +8,25 @@ from app.adapters.auth import outbox_dispatch_authorization from app.adapters.outbox import outbox_delivery, production_outbox_delivery from app.adapters.tasks import evaluation_completion_handler -from app.modules.checkers.api.execution import COMPLETION_EVENT +from app.modules.checkers.api.execution import COMPLETION_EVENT, REQUEST_EVENT +from app.modules.checkers.api.external_policy import ( + MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS, + SELECTED_POST_DELIVERY_LEASE_SECONDS, + SELECTED_POST_FINALIZATION_MARGIN_MS, + SELECTED_POST_HANDLER_PRELUDE_MARGIN_MS, + SELECTED_POST_HANDLER_TIMEOUT_SECONDS, +) from app.modules.outbox.api import HandlerOutcome, FinalizationCause, DeliveryOptions from app.modules.outbox.registry import HandlerRegistry from app.modules.tasks.post_submit_routing.outcome import TaskPostSubmitOutcome from tests.authorization.post_submit_routing.support import provision_router from tests.tasks.post_submit_routing.test_source_preparation import routing_source -from tests.tasks.post_submit_routing.outcome_support import authorized_routing_source, outcome_snapshot -from .support import delivery_fixture, outcome +from tests.tasks.post_submit_routing.outcome_support import ( + authorized_routing_source, + outcome_snapshot, +) +from tests.post_submit_materialization_helpers import material_fixture +from .support import delivery_fixture, outcome, state async def completion_delivery(h, handler=None): @@ -23,20 +34,37 @@ async def completion_delivery(h, handler=None): await provision_router(h.factory) await delivery_fixture(h) return outbox_delivery( - h.factory, authorization_factory=outbox_dispatch_authorization, options=DeliveryOptions(), - registry=HandlerRegistry([(COMPLETION_EVENT, 1, handler or evaluation_completion_handler(sessions=h.factory))]), + h.factory, + authorization_factory=outbox_dispatch_authorization, + options=DeliveryOptions(), + registry=HandlerRegistry( + [(COMPLETION_EVENT, 1, handler or evaluation_completion_handler(sessions=h.factory))] + ), ) async def assert_committed(h, *, human, award_count): async with h.factory() as session: - assert await session.scalar(text("SELECT status FROM public.workstream_tasks WHERE id=:id"), - {"id": str(h.request.task_id)}) == ("review_pending" if human else "accepted") - for table, count in (("task_post_submit_routing_manifests", 1), ("final_acceptances", int(not human)), - ("contribution_records", int(not human)), ("compensation_awards", award_count), - ("reviews", 0)): + assert await session.scalar( + text("SELECT status FROM public.workstream_tasks WHERE id=:id"), + {"id": str(h.request.task_id)}, + ) == ("review_pending" if human else "accepted") + for table, count in ( + ("task_post_submit_routing_manifests", 1), + ("final_acceptances", int(not human)), + ("contribution_records", int(not human)), + ("compensation_awards", award_count), + ("reviews", 0), + ): assert await session.scalar(text(f"SELECT count(*) FROM public.{table}")) == count - assert await session.scalar(text("SELECT public.task_routing_outcome_complete(m) FROM public.task_post_submit_routing_manifests m")) is True + assert ( + await session.scalar( + text( + "SELECT public.task_routing_outcome_complete(m) FROM public.task_post_submit_routing_manifests m" + ) + ) + is True + ) async def commit_before_ack(h, monkeypatch, *, human, award_count): @@ -60,12 +88,19 @@ async def acknowledge(envelope): return result delivery = await completion_delivery(h, acknowledge) - running = asyncio.create_task(delivery.deliver(h.source["completion_event_id"], h.request.project_id, "completion")) + running = asyncio.create_task( + delivery.deliver(h.source["completion_event_id"], h.request.project_id, "completion") + ) try: await asyncio.wait_for(staged.wait(), 10) assert not running.done() async with h.factory() as session: - assert await session.scalar(text("SELECT count(*) FROM public.task_post_submit_routing_manifests")) == 0 + assert ( + await session.scalar( + text("SELECT count(*) FROM public.task_post_submit_routing_manifests") + ) + == 0 + ) assert await session.scalar(text("SELECT count(*) FROM public.final_acceptances")) == 0 release.set() receipt = await asyncio.wait_for(running, 10) @@ -78,7 +113,9 @@ async def acknowledge(envelope): await asyncio.gather(running, return_exceptions=True) -async def test_completion_handler_true_commits_before_ack_without_rev(tmp_path, isolated_database_env, monkeypatch): +async def test_completion_handler_true_commits_before_ack_without_rev( + tmp_path, isolated_database_env, monkeypatch +): from app.adapters.tasks.routing_acceptance import RoutingAcceptanceAdapter async def forbidden(*args, **kwargs): @@ -92,20 +129,33 @@ async def forbidden(*args, **kwargs): @pytest.mark.parametrize("awards", [(), ("money", "project_points")]) async def test_completion_handler_false_commits_acceptance_before_ack( - tmp_path, isolated_database_env, live_acceptance_lifecycle, monkeypatch, awards, + tmp_path, + isolated_database_env, + live_acceptance_lifecycle, + monkeypatch, + awards, ): - async with routing_source(tmp_path, isolated_database_env, human_review_required=False, contribution_awards=awards) as h: + async with routing_source( + tmp_path, isolated_database_env, human_review_required=False, contribution_awards=awards + ) as h: await commit_before_ack(h, monkeypatch, human=False, award_count=len(awards)) @pytest.mark.parametrize("human", [True, False]) -async def test_completion_handler_committed_replay_is_exact(tmp_path, isolated_database_env, live_acceptance_lifecycle, human): - async with authorized_routing_source(tmp_path, isolated_database_env, human_review_required=human) as h: +async def test_completion_handler_committed_replay_is_exact( + tmp_path, isolated_database_env, live_acceptance_lifecycle, human +): + async with authorized_routing_source( + tmp_path, isolated_database_env, human_review_required=human + ) as h: handler = evaluation_completion_handler(sessions=h.factory) assert await handler(h.envelope) is HandlerOutcome.ACKNOWLEDGE if not human: from tests.reviews.lifecycle.transition_support import command_for, transition - stopped = await transition(await command_for(live_acceptance_lifecycle.target.id, "draining")) + + stopped = await transition( + await command_for(live_acceptance_lifecycle.target.id, "draining") + ) assert stopped.generation == 3 async with h.factory() as session: saved = await outcome_snapshot(session) @@ -116,5 +166,124 @@ async def test_completion_handler_committed_replay_is_exact(tmp_path, isolated_d assert await handler(h.envelope) is HandlerOutcome.REJECT -def test_completion_handler_is_absent_from_production_registry(): - assert (COMPLETION_EVENT, 1) not in production_outbox_delivery(None)._registry.keys +def test_completion_handler_is_registered_in_production_registry(): + delivery = production_outbox_delivery(None) + assert (COMPLETION_EVENT, 1) in delivery._registry.keys + assert delivery._options_for(COMPLETION_EVENT, 1) == DeliveryOptions() + + +def test_production_request_transport_covers_maximum_selected_execution(monkeypatch): + from app.core.config import get_settings + + # This checks the actual task declaration without publishing to a broker. + monkeypatch.setenv("WORKSTREAM_CELERY_BROKER_URL", "memory://") + monkeypatch.setenv("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") + get_settings.cache_clear() + try: + _assert_production_request_transport_budget() + finally: + get_settings.cache_clear() + + +def _assert_production_request_transport_budget(): + from app.workers.outbox import deliver_event + from app.workers.outbox_topology import OUTBOX_PROCESS_SHUTDOWN_MARGIN_SECONDS + + delivery = production_outbox_delivery(None) + request = delivery._options_for(REQUEST_EVENT, 1) + + assert request.handler_timeout_seconds == SELECTED_POST_HANDLER_TIMEOUT_SECONDS + assert request.lease_seconds == SELECTED_POST_DELIVERY_LEASE_SECONDS + bounded_pipeline_ms = ( + MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS + + SELECTED_POST_HANDLER_PRELUDE_MARGIN_MS + + SELECTED_POST_FINALIZATION_MARGIN_MS + ) + assert bounded_pipeline_ms <= request.handler_timeout_seconds * 1000 + assert request.handler_timeout_seconds < request.lease_seconds <= 3600 + assert bounded_pipeline_ms < request.lease_seconds * 1000 + assert deliver_event.time_limit == ( + request.lease_seconds + OUTBOX_PROCESS_SHUTDOWN_MARGIN_SECONDS + ) + assert deliver_event.time_limit > request.handler_timeout_seconds + assert delivery._options_for(COMPLETION_EVENT, 1) == DeliveryOptions() + + +@pytest.mark.parametrize("human", [True, False]) +async def test_production_registry_delivers_request_then_completion_exactly_once( + tmp_path, + isolated_database_env, + live_acceptance_lifecycle, + monkeypatch, + human, +): + """Production registration must consume its own emitted completion event.""" + from app.adapters import artifacts as artifact_adapters + + awards = () if human else ("money", "project_points") + async with material_fixture( + tmp_path, + isolated_database_env, + human_review_required=human, + contribution_awards=awards, + ) as h: + # The shared ART fixture arranges an in-progress assignment without + # proving contributor start authority; routing requires accepted custody. + async with h.factory() as session, session.begin(): + accepted = await session.execute(text( + "UPDATE public.task_assignments SET accepted_at=clock_timestamp() " + "WHERE id=:id AND accepted_at IS NULL" + ), {"id": str(h.request.assignment_id)}) + assert accepted.rowcount == 1 + await provision_router(h.factory) + await delivery_fixture(h) + # An empty locked POST policy completes without constructing or + # contacting the external service. Actual image execution is covered + # by the production Rust/MinIO probe. + settings = h.settings.model_copy(update={ + "external_checker_service_socket": None, + "external_checker_material_root": None, + }) + monkeypatch.setattr("app.core.config.get_settings", lambda: settings) + monkeypatch.setattr( + artifact_adapters, + "worker_post_submission_materialization", + lambda *, sessions: h.service, + ) + delivery = production_outbox_delivery(h.factory) + assert delivery._registry.keys[-2:] == ( + (REQUEST_EVENT, 1), + (COMPLETION_EVENT, 1), + ) + + request_receipt = await delivery.deliver( + h.created.evaluation_event_id, + h.request.project_id, + "production-request", + ) + assert outcome(request_receipt)["delivery_state"] == "acknowledged", outcome(request_receipt) + completed = await state(h) + assert completed["run"]["status"] == "completed" + assert completed["members"] == 0 + completion_event_id = completed["run"]["completion_event_id"] + assert completion_event_id is not None + + completion_receipt = await delivery.deliver( + completion_event_id, + h.request.project_id, + "production-completion", + ) + assert outcome(completion_receipt)["delivery_state"] == "acknowledged", outcome(completion_receipt) + await assert_committed(h, human=human, award_count=len(awards)) + async with h.factory() as session: + snapshot = await outcome_snapshot(session) + assert ( + await delivery.deliver( + completion_event_id, + h.request.project_id, + "production-completion-replay", + ) + is None + ) + async with h.factory() as session: + assert await outcome_snapshot(session) == snapshot diff --git a/backend/tests/tasks/evaluation_delivery/test_custody.py b/backend/tests/tasks/evaluation_delivery/test_custody.py index b50059d10..b65e4ef22 100644 --- a/backend/tests/tasks/evaluation_delivery/test_custody.py +++ b/backend/tests/tasks/evaluation_delivery/test_custody.py @@ -30,15 +30,23 @@ async def test_expired_execution_is_not_renewed(tmp_path, isolated_database_env, async with material_fixture(tmp_path, isolated_database_env) as h: d = await delivery_fixture(h) envelope = await invoked(h, d) + executor = d.handler._executor(envelope, h.request) with monkeypatch.context() as patch: - patch.setattr(execution, "LEASE_SECONDS", 1) - lease, replay = await d.handler._executor(envelope, h.request)._claim(h.request) + patch.setattr(executor, "_execution_budget_seconds", lambda _request: 1) + patch.setattr(execution, "SELECTED_POST_FINALIZATION_MARGIN_MS", 9_000) + lease, replay = await executor._claim(h.request) assert replay is None and lease.lease_generation == 1 before = await state(h) async with h.factory() as session: + assert await session.scalar(text( + "SELECT EXTRACT(EPOCH FROM worker_lease_expires_at-started_at) " + "FROM public.checker_runs WHERE id=:id" + ), {"id": lease.reservation.attempt_id}) == 10 await session.execute(text("select pg_sleep(greatest(0, extract(epoch from " "(:deadline - clock_timestamp()))) + 0.02)"), {"deadline": lease.expires_at}) + assert await session.scalar(text("SELECT clock_timestamp()>=:expiry"), + {"expiry": lease.expires_at}) with pytest.raises(CheckerExecutionUnavailable, match="checker_delivery_execution_unavailable"): await d.handler(envelope) assert await state(h) == before and not h.store.opens and not h.preparation._active diff --git a/backend/tests/tasks/evaluation_delivery/test_delivery.py b/backend/tests/tasks/evaluation_delivery/test_delivery.py index 97f6fd8b9..bf1b464fe 100644 --- a/backend/tests/tasks/evaluation_delivery/test_delivery.py +++ b/backend/tests/tasks/evaluation_delivery/test_delivery.py @@ -25,7 +25,7 @@ async def test_shared_delivery_runs_exact_request_once(tmp_path, isolated_databa await provision_minio_bucket.__wrapped__() async with material_fixture(tmp_path, isolated_database_env, provider=provider) as h: d = await delivery_fixture(h) - assert (REQUEST_EVENT, 1) not in production_outbox_delivery(h.factory)._registry.keys + assert (REQUEST_EVENT, 1) in production_outbox_delivery(h.factory)._registry.keys receipt = await d.delivery.deliver(h.created.evaluation_event_id, h.request.project_id, "worker") assert outcome(receipt)["delivery_state"] == "acknowledged" saved = await state(h) diff --git a/backend/tests/tasks/evaluation_delivery/test_worker_materialization.py b/backend/tests/tasks/evaluation_delivery/test_worker_materialization.py index b46f591b2..83a7d9584 100644 --- a/backend/tests/tasks/evaluation_delivery/test_worker_materialization.py +++ b/backend/tests/tasks/evaluation_delivery/test_worker_materialization.py @@ -66,7 +66,7 @@ async def test_worker_art_lease_executes_exact_hidden_request( port = _worker_port(h, monkeypatch) try: d = await delivery_fixture(h, materialization=port) - assert (REQUEST_EVENT, 1) not in production_outbox_delivery(h.factory)._registry.keys + assert (REQUEST_EVENT, 1) in production_outbox_delivery(h.factory)._registry.keys receipt = await d.delivery.deliver( h.created.evaluation_event_id, h.request.project_id, "worker-runtime", ) diff --git a/backend/tests/tasks/invalidation_support.py b/backend/tests/tasks/invalidation_support.py index ea19214d4..a38180ba3 100644 --- a/backend/tests/tasks/invalidation_support.py +++ b/backend/tests/tasks/invalidation_support.py @@ -299,7 +299,7 @@ async def prepare_submission(s, settings): from app.modules.tasks.api import SubmissionCreationRequest from tests.tasks.submission_lineage_support import _seed_services, _verified_admission from tests.test_artifact_admission import _namespace, _local_store, _context - from tests.test_default_pre_submit_execution import _archive, _bytes + from tests.archive_fixtures import byte_source as _bytes, submission_archive as _archive from tests.test_tasks import complete_submission_payload context = _context( diff --git a/backend/tests/tasks/post_submit_routing/support.py b/backend/tests/tasks/post_submit_routing/support.py index 1b972173b..2dcc83ab4 100644 --- a/backend/tests/tasks/post_submit_routing/support.py +++ b/backend/tests/tasks/post_submit_routing/support.py @@ -26,7 +26,7 @@ from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test from tests.tasks.submission_lineage_support import _verified_admission from tests.test_artifact_admission import _context -from tests.test_default_pre_submit_execution import _bytes +from tests.archive_fixtures import byte_source as _bytes SOURCE_COLUMNS = ( @@ -78,13 +78,12 @@ async def source_rows(session) -> list[dict]: ) -async def next_request(h, *, structural_input=None): +async def next_request(h): """Build a genuine later generation for the same immutable Submission.""" request = change_request( h.request, evaluation_request_id=new_record_id(), evaluation_generation=h.request.evaluation_generation + 1, - **({"structural_input": structural_input} if structural_input is not None else {}), ) h.request = request return request @@ -215,6 +214,7 @@ async def completed_source( storage_settings=None, contribution_awards=(), human_review_required=True, + post_submit_registry_spec=None, ): """Yield one real authorized allow-review run and its valid source scalars.""" async with material_fixture( @@ -224,6 +224,7 @@ async def completed_source( storage_settings=storage_settings, contribution_awards=contribution_awards, human_review_required=human_review_required, + post_submit_registry_spec=post_submit_registry_spec, ) as h: await reserve(h) result = await live_executor(h).evaluate_post_submission(h.request) @@ -252,8 +253,8 @@ async def _create_submission(h, context, task_id, assignment_id, predecessor_id= assignment_id=assignment_id, predecessor_submission_id=predecessor_id, idempotency_key=new_record_id(), - summary=h.request.structural_input.summary, - contributor_attestation=h.request.structural_input.worker_attestation, + summary=h.creation_request.summary, + contributor_attestation=h.creation_request.contributor_attestation, media_type="application/zip", byte_source=_bytes(h.data), ) @@ -436,13 +437,13 @@ async def activate_successor_guide(h): def completion_for(h): """Build the actual completed publication from retained fixture facts.""" from app.modules.checkers.api.execution import EvaluationCompletion - from app.modules.checkers.api.post_submit import PostSubmitCurrentResultReference + from app.modules.checkers.api.selected_post_submit import SelectedPostSubmitCurrentResultReference return EvaluationCompletion( project_id=h.request.project_id, task_id=h.request.task_id, submission_id=h.request.submission_id, - reference=PostSubmitCurrentResultReference( - **h.result.model_dump(include=set(PostSubmitCurrentResultReference.model_fields) - {"schema_version"}) + reference=SelectedPostSubmitCurrentResultReference( + **h.result.model_dump(include=set(SelectedPostSubmitCurrentResultReference.model_fields) - {"schema_version"}) ), routing_recommendation="allow_review", output_binding_ids=(), execute_evidence_id=h.source["execute_evidence_id"], diff --git a/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py b/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py index 6e412f7e7..03ea290ff 100644 --- a/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py +++ b/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py @@ -91,9 +91,7 @@ async def compete(): {"id": original.attempt_id}) await session.execute(text("SET LOCAL lock_timeout='250ms'")) assert await evaluation_coordinator(session).reserve_current_evaluation(h.request) == original - changed = change_request(h.request, structural_input=h.request.structural_input.model_copy( - update={"summary": "Changed envelope, unchanged immutable TASK lineage."}, - )) + changed = change_request(h.request, byte_count=h.request.byte_count + 1) with pytest.raises(CheckerRequestConflict, match="checker_request_conflict"): await evaluation_coordinator(session).reserve_current_evaluation(changed) fence = await session.get(CheckerSubmissionFence, str(h.request.submission_id)) diff --git a/backend/tests/tasks/post_submit_routing/test_evaluation_guard.py b/backend/tests/tasks/post_submit_routing/test_evaluation_guard.py index 4f7501e2b..00faaa39a 100644 --- a/backend/tests/tasks/post_submit_routing/test_evaluation_guard.py +++ b/backend/tests/tasks/post_submit_routing/test_evaluation_guard.py @@ -113,10 +113,7 @@ async def test_changed_locked_context_cannot_advance_generation(tmp_path, isolat for field in ("source_id", "effective_policy_id", "pre_policy_id", "review_policy_id", "revision_policy_id"): context = h.request.expected_context.model_copy(update={field: new_record_id()}) changed = change_request(h.request, expected_context=context, - structural_input=h.request.structural_input.model_copy(update={ - "observed_context": h.request.structural_input.observed_context.model_copy( - update={field: getattr(context, field)}), - }), evaluation_request_id=new_record_id(), evaluation_generation=2) + evaluation_request_id=new_record_id(), evaluation_generation=2) async with h.factory() as session, session.begin(): with pytest.raises(CheckerExecutionUnavailable, match="checker_reservation_scope_unavailable"): await evaluation_coordinator(session).reserve_current_evaluation(changed) diff --git a/backend/tests/tasks/post_submit_routing/test_failure_completion.py b/backend/tests/tasks/post_submit_routing/test_failure_completion.py deleted file mode 100644 index 24c262d64..000000000 --- a/backend/tests/tasks/post_submit_routing/test_failure_completion.py +++ /dev/null @@ -1,40 +0,0 @@ -"""A verified checker failure still grants no success routing or acceptance authority.""" - -import pytest -from sqlalchemy import func, select, text - -from app.adapters.checkers import evaluation_coordinator -from app.adapters.tasks import evaluation_completion_handler, routing_source_preparer, task_post_submit_outcome -from app.modules.outbox.api import HandlerOutcome -from app.modules.tasks.post_submit_routing.models import TaskRoutingRequest, TaskPostSubmitRoutingManifest -from app.modules.tasks.post_submit_routing.requests import TaskRoutingRequests, TaskRoutingRequestUnavailable -from tests.checkers.execution.completion_fixture import completed_evidence, evidence_snapshot -from tests.checkers.execution.test_completion_evidence import verify -from .outcome_support import invoked_completion - - -@pytest.mark.parametrize("recommendation", ["task_setup_blocked"]) -async def test_stored_failure_cannot_enter_task_routing_or_completion_delivery(tmp_path, isolated_database_env, recommendation): - async with completed_evidence(tmp_path, isolated_database_env, recommendation) as h: - async with h.factory() as session, session.begin(): - await session.execute(text("UPDATE public.task_assignments SET accepted_at=clock_timestamp() WHERE id=:id"), - {"id": h.request.assignment_id}) - envelope = await invoked_completion(h) - async with h.factory() as session, session.begin(): - # The same actual stored failure is valid for CHECKERS observation. - assert (await verify(session, h)).completion.routing_recommendation == recommendation - before = await evidence_snapshot(session) - with pytest.raises(TaskRoutingRequestUnavailable, match="routing_request_unavailable"): - await TaskRoutingRequests(session, evaluation_coordinator(session)).stage( - h.source["completion_event_id"], h.completion, - ) - with pytest.raises(TaskRoutingRequestUnavailable, match="routing_request_unavailable"): - await routing_source_preparer(session).prepare(h.source["completion_event_id"], h.completion) - with pytest.raises(TaskRoutingRequestUnavailable, match="routing invocation unavailable"): - await task_post_submit_outcome(session, h.factory).apply(envelope, current_generation=None) - assert await evidence_snapshot(session) == before - assert await session.scalar(select(func.count()).select_from(TaskRoutingRequest)) == 0 - assert await session.scalar(select(func.count()).select_from(TaskPostSubmitRoutingManifest)) == 0 - assert await evaluation_completion_handler(sessions=h.factory)(envelope) is HandlerOutcome.REJECT - async with h.factory() as session: - assert await evidence_snapshot(session) == before diff --git a/backend/tests/tasks/post_submit_routing/test_migration.py b/backend/tests/tasks/post_submit_routing/test_migration.py index 23fb7f2f9..c2b10ede7 100644 --- a/backend/tests/tasks/post_submit_routing/test_migration.py +++ b/backend/tests/tasks/post_submit_routing/test_migration.py @@ -7,10 +7,9 @@ from alembic import command from app.db import session as db_session -from tests.migration_fixtures import add_current_art_seed_column, restore_predecessor_evidence_schema from tests.migration_fixtures import _config -from tests.checkers.execution.historical_execution import historical_completed_source +from tests.checkers.execution.historical_execution import archived_completed_checker_source pytestmark = pytest.mark.postgres_schema_contract @@ -18,13 +17,13 @@ async def _snapshot(connection, h): identifiers = { - "submission": h.request.submission_id, - "run": h.result.attempt_id, + "submission": h.request["submission_id"], + "run": h.result["attempt_id"], "execute": h.source["execute_evidence_id"], "finalize": h.source["finalize_evidence_id"], "completion": h.source["completion_event_id"], - "task": h.request.task_id, - "assignment": h.request.assignment_id, + "task": h.request["task_id"], + "assignment": h.request["assignment_id"], } return { "submission": await connection.fetchval( @@ -81,9 +80,7 @@ async def test_upgrade_preserves_existing_sources_without_publishing( await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0010_post_submit_authority") - original_columns = await add_current_art_seed_column(isolated_database_env) - async with historical_completed_source(tmp_path, isolated_database_env) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) + async with archived_completed_checker_source(tmp_path, isolated_database_env) as h: connection = await asyncpg.connect(url) try: assert await connection.fetchval( @@ -124,9 +121,7 @@ async def test_request_upgrade_preserves_completed_owners( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0017_acceptance_source_contracts") - original_columns = await add_current_art_seed_column(isolated_database_env) - async with historical_completed_source(tmp_path, isolated_database_env) as h: - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) + async with archived_completed_checker_source(tmp_path, isolated_database_env) as h: connection = await asyncpg.connect(url) try: before = await _snapshot(connection, h) diff --git a/backend/tests/tasks/post_submit_routing/test_outcome_migration.py b/backend/tests/tasks/post_submit_routing/test_outcome_migration.py index d62fd75a1..c1e7c49b4 100644 --- a/backend/tests/tasks/post_submit_routing/test_outcome_migration.py +++ b/backend/tests/tasks/post_submit_routing/test_outcome_migration.py @@ -9,8 +9,8 @@ from app.db import session as db_session from tests.checkers.execution.historical_execution import ( - historical_completed_source, - historical_reserve, + archived_completed_checker_source, + seed_archived_checker_request, ) from tests.historical_submission_fixtures import historical_material_fixture from tests.migration_fixtures import ( @@ -55,10 +55,10 @@ async def test_pre_authority_rows_refuse_upgrade_without_rewriting( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0024_require_second_review_false") - factory = historical_completed_source if terminal else historical_material_fixture + factory = archived_completed_checker_source if terminal else historical_material_fixture async with factory(tmp_path, isolated_database_env) as h: if not terminal: - await historical_reserve(h) + await seed_archived_checker_request(h) await asyncio.to_thread(command.upgrade, _config(), "0029_external_checker_registry") connection = await asyncpg.connect(url) try: diff --git a/backend/tests/tasks/post_submit_routing/test_remediation_migration.py b/backend/tests/tasks/post_submit_routing/test_remediation_migration.py index 811e35aff..aa9d22e68 100644 --- a/backend/tests/tasks/post_submit_routing/test_remediation_migration.py +++ b/backend/tests/tasks/post_submit_routing/test_remediation_migration.py @@ -1,39 +1,19 @@ """0032 preserves genuine retained success outcomes and their exact evidence.""" import asyncio +import json import asyncpg import pytest from alembic import command from app.db import session as db_session -from scripts.bootstrap_access_administrator import _run as run_bootstrap -from tests.migration_fixtures import ( - _add_current_cause_seed_columns, - _config, - _restore_current_cause_seed_columns, -) -from tests.reviews.lifecycle.transition_support import command_for, transition -from tests.tasks.post_submit_routing.outcome_support import ( - apply_outcome, - authorized_routing_source, -) +from tests.historical_submission_fixtures import restore_archived_snapshot +from tests.migration_fixtures import _config pytestmark = pytest.mark.postgres_schema_contract -async def _activate_acceptance_lifecycle(access) -> None: - """Use signed human authority to activate the retained false-policy branch.""" - administrator = await access.signed.actor("migration-administrator") - operator = await access.signed.actor("migration-operator") - code, result = await run_bootstrap(administrator.id, execute=True) - assert code == 0 and result["result_code"] == "bootstrapped" - await access.signed.grant(administrator, operator) - await transition(await command_for(operator.id, "shadow")) - live = await transition(await command_for(operator.id, "live")) - assert live.generation == 2 - - async def _snapshot(connection) -> dict[str, list[asyncpg.Record]]: """Capture every retained owner touched by routing, excluding only the new column.""" queries = { @@ -71,7 +51,7 @@ async def _routing_validity(connection) -> list[asyncpg.Record]: """Return exact receipt hashes and all success-closure predicates.""" return await connection.fetch( """ - SELECT m.id, m.human_review_required, + SELECT m.id, m.human_review_required, m.final_acceptance_id, m.authority_context->'source'->>'routing_recommendation' AS source_recommendation, public.task_routing_resource_digest(m) AS resource_digest, a.after_facts->>'resource_context_digest' AS receipt_digest, @@ -94,12 +74,10 @@ async def _routing_validity(connection) -> list[asyncpg.Record]: async def test_routing_upgrade_preserves_success_evidence( - tmp_path, isolated_database_env, migration_lock, - admin_access, ): - """Upgrade real 0031 true/false outcomes without rewriting their evidence.""" + """Upgrade captured 0031 true/false outcomes without rewriting their evidence.""" url = isolated_database_env.replace("+asyncpg", "") with migration_lock(): await db_session.dispose_engine() @@ -109,53 +87,33 @@ async def test_routing_upgrade_preserves_success_evidence( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0031_task_import_source") - - # Current application construction needs later nullable columns while it - # writes genuine predecessor outcomes. Restore exact 0031 shape before upgrade. - connection = await asyncpg.connect(url) - try: - await connection.execute( - "ALTER TABLE public.task_post_submit_routing_manifests " - "ADD COLUMN routing_recommendation varchar(32) NOT NULL" - ) - cause_originals = await _add_current_cause_seed_columns(connection) - finally: - await connection.close() - - await _activate_acceptance_lifecycle(admin_access) - async with authorized_routing_source( - tmp_path / "human", + await restore_archived_snapshot( isolated_database_env, - human_review_required=True, - ) as human: - async with human.factory() as session, session.begin(): - human_result = await apply_outcome(session, human, None) - async with authorized_routing_source( - tmp_path / "automatic", - isolated_database_env, - human_review_required=False, - storage_settings=human.settings, - provision_services=False, - ) as automatic: - async with automatic.factory() as session, session.begin(): - automatic_result = await apply_outcome(session, automatic, 2) - - assert human_result.final_acceptance_id is None - assert automatic_result.final_acceptance_id is not None + snapshot_name="0031_routing_success_true_false", + ) await db_session.dispose_engine() connection = await asyncpg.connect(url) try: - await _restore_current_cause_seed_columns(connection, cause_originals) - await connection.execute( - "ALTER TABLE public.task_post_submit_routing_manifests " - "DROP COLUMN routing_recommendation" - ) assert await connection.fetchval( "SELECT version_num FROM public.alembic_version" ) == "0031_task_import_source" before = await _snapshot(connection) before_validity = await _routing_validity(connection) assert len(before_validity) == 2 + human, automatic = before_validity + assert [row["human_review_required"] for row in before_validity] == [True, False] + assert human["final_acceptance_id"] is None + assert automatic["final_acceptance_id"] is not None + assert len(before["requests"]) == 2 + assert len(before["acceptances"]) == 1 + assert len(before["contributions"]) == 1 + acceptance = json.loads(before["acceptances"][0]["value"]) + contribution = json.loads(before["contributions"][0]["value"]) + assert acceptance["id"] == str(automatic["final_acceptance_id"]) + assert acceptance["source_routing_manifest_id"] == str(automatic["id"]) + assert contribution["source_final_acceptance_id"] == acceptance["id"] + assert contribution["submission_id"] == acceptance["submission_id"] + assert contribution["task_id"] == acceptance["task_id"] assert all( row["source_recommendation"] == "allow_review" and row["resource_digest"] == row["receipt_digest"] diff --git a/backend/tests/tasks/post_submit_routing/test_requests.py b/backend/tests/tasks/post_submit_routing/test_requests.py index dd85ca052..0c00a9361 100644 --- a/backend/tests/tasks/post_submit_routing/test_requests.py +++ b/backend/tests/tasks/post_submit_routing/test_requests.py @@ -193,10 +193,10 @@ def untrusted_completion(): """Valid value shape only; empty database ensures root denial precedes owner reads.""" from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import EvaluationCompletion - from app.modules.checkers.api.post_submit import PostSubmitCurrentResultReference + from app.modules.checkers.api.selected_post_submit import SelectedPostSubmitCurrentResultReference return new_record_id(), EvaluationCompletion( project_id=new_record_id(), task_id=new_record_id(), submission_id=new_record_id(), - reference=PostSubmitCurrentResultReference( + reference=SelectedPostSubmitCurrentResultReference( request_id=new_record_id(), request_digest="sha256:" + "a" * 64, attempt_id=new_record_id(), evaluation_generation=1, result_id=new_record_id(), result_digest="sha256:" + "b" * 64, diff --git a/backend/tests/tasks/submission_dispatch/test_concurrency.py b/backend/tests/tasks/submission_dispatch/test_concurrency.py index 584d007c2..7f113dfa5 100644 --- a/backend/tests/tasks/submission_dispatch/test_concurrency.py +++ b/backend/tests/tasks/submission_dispatch/test_concurrency.py @@ -104,7 +104,7 @@ async def _role_race(tmp_path, database_url, monkeypatch, operation): from app.modules.authorization.submission_creation_authorization import PreparedSubmissionCreationAuthorization from app.modules.tasks.models import WorkstreamTask from tests.test_artifact_admission import _context, _seed_human_actor - from tests.test_pre_submit_role_issue_lock_order import _issue_body, _issue_runtime + from tests.project_role_issue_fixtures import issue_body, issue_runtime async def writer(factory, context, request): manager = _context() @@ -135,10 +135,12 @@ async def create(): ).create(request) async def mutate(): async with role_factory() as session: - prepared, resolved = await _issue_runtime(session, manager.actor_profile_id, manager.identity_link_id) + prepared, resolved = await issue_runtime( + session, manager.actor_profile_id, manager.identity_link_id + ) if operation == "issue": return await router.issue_project_role_grant( - project_id=project_id, payload=_issue_body(context.actor_profile_id), + project_id=project_id, payload=issue_body(context.actor_profile_id), idempotency_key=new_record_id(), resolved=resolved, prepared=prepared, session=session, ) return await router.revoke_project_role_grant( diff --git a/backend/tests/tasks/submission_dispatch/test_migration.py b/backend/tests/tasks/submission_dispatch/test_migration.py index 51bece90d..8617d0144 100644 --- a/backend/tests/tasks/submission_dispatch/test_migration.py +++ b/backend/tests/tasks/submission_dispatch/test_migration.py @@ -36,17 +36,31 @@ async def snapshot(): before = await snapshot() await asyncio.to_thread(command.upgrade, _config(), current_schema_revision()) after = await snapshot() - before_admissions = json.loads(before.pop("submission_bundle_admissions")) - after_admissions = json.loads(after.pop("submission_bundle_admissions")) - assert len(after_admissions) == len(before_admissions) - for retained, upgraded in zip( - before_admissions, after_admissions, strict=True - ): - assert set(upgraded) == set(retained) | { - "checker_remediation_manifest_id" - } - assert upgraded.pop("checker_remediation_manifest_id") is None - assert upgraded == retained + added_columns = { + "submissions": { + "selected_post_submit_policy_sha256", "selected_post_submit_policy_body", + }, + "checker_runs": { + "selected_post_submit_policy_sha256", "selected_post_submit_policy_body", + }, + "submission_bundle_admissions": { + "checker_remediation_manifest_id", "selected_pre_submit_run_id", + "guide_id", "guide_version", "source_snapshot_id", "source_snapshot_sha256", + "effective_policy_id", "effective_policy_sha256", "pre_submit_policy_id", + "pre_submit_policy_sha256", "effective_plan_sha256", "selected_rules_sha256", + "selected_post_submit_policy_sha256", "selected_post_submit_policy_body", + "packet_sha256", "semantic_manifest_body", + }, + } + for table, columns_added in added_columns.items(): + retained_rows = sorted(json.loads(before.pop(table)), key=lambda row: row["id"]) + upgraded_rows = sorted(json.loads(after.pop(table)), key=lambda row: row["id"]) + assert len(upgraded_rows) == len(retained_rows) + for retained, upgraded in zip(retained_rows, upgraded_rows, strict=True): + assert set(upgraded) == set(retained) | columns_added + for column in columns_added: + assert upgraded.pop(column) is None + assert upgraded == retained assert after == before columns = await connection.fetch(""" SELECT table_name,data_type,is_nullable @@ -76,6 +90,6 @@ async def snapshot(): ) == 0 assert await connection.fetchval("SELECT count(*) FROM public.submission_dispatches") == 0 assert await connection.fetchval("SELECT count(*) FROM public.submission_binding_receipts") == 0 - assert await connection.fetchval("SELECT status FROM public.submission_bundle_admissions WHERE id=$1", h.created.admission_id) == "consumed" + assert await connection.fetchval("SELECT status FROM public.submission_bundle_admissions WHERE id=$1", h.created["admission_id"]) == "consumed" finally: await connection.close() diff --git a/backend/tests/tasks/submission_dispatch/test_replacement_migration.py b/backend/tests/tasks/submission_dispatch/test_replacement_migration.py index 70ad4a94a..4076e703e 100644 --- a/backend/tests/tasks/submission_dispatch/test_replacement_migration.py +++ b/backend/tests/tasks/submission_dispatch/test_replacement_migration.py @@ -1,12 +1,6 @@ """0034 preserves initial custody and refuses uncaused retained replacements.""" import asyncio -import io -import os -from pathlib import Path -import subprocess -import sys -import tarfile import asyncpg import pytest @@ -14,83 +8,13 @@ from sqlalchemy.exc import DBAPIError from app.db import session as db_session +from tests.historical_submission_fixtures import restore_archived_snapshot from tests.migration_fixtures import _config pytestmark = pytest.mark.postgres_schema_contract PREDECESSOR = "0033_contribution_reads" SUCCESSOR = "0034_checker_replacement" -# The last merged 0032 writer could produce an authorized revision without a -# persisted checker-remediation cause; current runtime deliberately cannot. -HISTORICAL_WRITER = "735cf26591e4ed616bcaa57f12eeb37cee741973" -HISTORICAL_SEED = """ -import asyncio -import os -from pathlib import Path -import sys - -from app.adapters.tasks import evaluation_completion_handler -from app.api.deps.authorization import compose_hidden_submission_creation_command -from app.core.identifiers import new_record_id -from app.db import session as db_session -from app.modules.artifacts.api import SubmissionBundlePreparationRequest -from app.modules.authorization.api import ActorIdentityFacts, ActorKind -from app.modules.outbox.api import HandlerOutcome -from app.modules.tasks.api import SubmissionCreationRequest -from tests.post_submit_materialization_helpers import material_fixture -from tests.tasks.post_submit_routing.remediation_support import remediation_source -from tests.tasks.post_submit_routing.support import revision_archive -from tests.tasks.submission_lineage_support import _verified_admission -from tests.test_default_pre_submit_execution import _bytes - - -async def seed(): - tmp_path = Path(sys.argv[2]) - database_url = os.environ["WS_MIGRATION_DATABASE_URL"] - if sys.argv[1] == "initial": - async with material_fixture(tmp_path, database_url) as h: - assert h.created.submission_version == 1 - else: - async with remediation_source(tmp_path, database_url) as h: - assert await evaluation_completion_handler(sessions=h.factory)(h.envelope) is HandlerOutcome.ACKNOWLEDGE - changed = revision_archive(h.data) - preparation = SubmissionBundlePreparationRequest( - actor=ActorIdentityFacts( - h.actor_context.actor_profile_id, - h.actor_context.identity_link_id, - ActorKind.HUMAN, - ), - request_id=new_record_id(), correlation_id=new_record_id(), - task_id=h.request.task_id, assignment_id=h.request.assignment_id, - predecessor_submission_id=h.created.submission_id, - idempotency_key=new_record_id(), - summary=h.request.structural_input.summary, - contributor_attestation=h.request.structural_input.worker_attestation, - media_type="application/zip", byte_source=_bytes(changed), - ) - admission_id = await _verified_admission( - h.factory, h.store, h.namespace, h.settings, h.actor_context, preparation, - ) - async with h.factory() as session: - created = await compose_hidden_submission_creation_command( - session, - h.actor_context.model_copy(update={ - "request_id": new_record_id(), "correlation_id": new_record_id(), - }), - request_id=new_record_id(), correlation_id=new_record_id(), - ).create(SubmissionCreationRequest( - task_id=h.request.task_id, assignment_id=h.request.assignment_id, - contributor_id=h.actor_context.actor_profile_id, - predecessor_submission_id=h.created.submission_id, - admission_id=admission_id, summary=preparation.summary, - contributor_attestation=preparation.contributor_attestation, - )) - assert created.submission_version == 2 - await db_session.dispose_engine() - - -asyncio.run(seed()) -""" RETAINED_TABLES = ( "submissions", "pre_submit_evidence_sets", @@ -122,34 +46,6 @@ async def _reset_to_predecessor(database_url): await asyncio.to_thread(command.upgrade, _config(), PREDECESSOR) -def _historical_backend(tmp_path): - root = Path(__file__).resolve().parents[4] - archived = subprocess.run( - ["git", "archive", "--format=tar", HISTORICAL_WRITER, "backend"], - cwd=root, - capture_output=True, - check=True, - ) - source = tmp_path / "historical" - with tarfile.open(fileobj=io.BytesIO(archived.stdout), mode="r:") as archive: - archive.extractall(source, filter="data") - return source / "backend" - - -def _seed_with_historical_writer(tmp_path, database_url, mode): - backend = _historical_backend(tmp_path) - subprocess.run( - [sys.executable, "-c", HISTORICAL_SEED, mode, str(tmp_path)], - cwd=backend, - env={ - **os.environ, - "PYTHONPATH": os.pathsep.join((str(backend), str(backend / "tests"))), - "WS_MIGRATION_DATABASE_URL": database_url, - }, - check=True, - ) - - async def _retained_rows(connection): """Exclude only the nullable cause columns introduced by 0034.""" return { @@ -181,15 +77,15 @@ async def _custody(connection): """) -async def test_upgrade_preserves_real_initial_rows_and_exact_receipts( +async def test_upgrade_preserves_archived_initial_rows_and_exact_receipts( tmp_path, isolated_database_env, migration_lock, ): with migration_lock(): await _reset_to_predecessor(isolated_database_env) - await asyncio.to_thread( - _seed_with_historical_writer, tmp_path, isolated_database_env, "initial" + await restore_archived_snapshot( + isolated_database_env, "0033_checker_replacement_initial" ) connection = await _connection(isolated_database_env) try: @@ -281,11 +177,11 @@ async def test_retained_uncaused_revision_refuses_upgrade_without_changes( isolated_database_env, migration_lock, ): - """A real 0032 writer can retain version two without a persisted source ID.""" + """The archived predecessor can retain version two without a source ID.""" with migration_lock(): await _reset_to_predecessor(isolated_database_env) - await asyncio.to_thread( - _seed_with_historical_writer, tmp_path, isolated_database_env, "revision" + await restore_archived_snapshot( + isolated_database_env, "0033_checker_replacement_revision" ) await db_session.dispose_engine() diff --git a/backend/tests/tasks/submission_lineage_support.py b/backend/tests/tasks/submission_lineage_support.py index d6feaf9c9..f6f5b1224 100644 --- a/backend/tests/tasks/submission_lineage_support.py +++ b/backend/tests/tasks/submission_lineage_support.py @@ -19,7 +19,6 @@ SubmissionBundleAdmission, ) from app.modules.artifacts.service import ArtifactStorageOrchestrator -from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue async def _seed_services(factory): @@ -29,6 +28,7 @@ async def _seed_services(factory): ServiceIdentity.ARTIFACT_PUT_RESOLVER, ServiceIdentity.ARTIFACT_VERIFIER, ServiceIdentity.ARTIFACT_BINDING, + ServiceIdentity.CHECKER_PRE_SUBMIT, ): actor_id, link_id = str(new_record_id()), str(new_record_id()) session.add( @@ -60,12 +60,7 @@ async def _verified_admission(factory, store, namespace, settings, context, requ { "type": "http", "headers": [], - "app": SimpleNamespace( - state=SimpleNamespace( - settings=settings, - pre_submission_checker_catalogue=build_pre_submission_checker_catalogue(), - ) - ), + "app": SimpleNamespace(state=SimpleNamespace(settings=settings)), } ) async with factory() as session: diff --git a/backend/tests/tasks/test_contribution_claim_races.py b/backend/tests/tasks/test_contribution_claim_races.py index 5cecf5ce1..6db7b9d35 100644 --- a/backend/tests/tasks/test_contribution_claim_races.py +++ b/backend/tests/tasks/test_contribution_claim_races.py @@ -21,7 +21,7 @@ from app.modules.tasks.models import TaskAssignment, WorkstreamTask from tests.auth_concurrency_support import wait_for_named_database_lock from tests.authorization.task_authority.test_concurrency import actor_context -from tests.test_pre_submit_role_issue_lock_order import _issue_body, _issue_role, _issue_runtime +from tests.project_role_issue_fixtures import issue_body, issue_role, issue_runtime from tests.test_tasks import ( task_database_env as task_database_env, task_client as task_client, @@ -51,7 +51,7 @@ async def test_role_issuance_and_claim_linearize_at_real_authority( ) manager_id, link_id = UUID(manager_link.actor_profile_id), UUID(manager_link.id) body = ProjectRoleGrantIssueBody.model_validate( - _issue_body(UUID(contributor)).model_dump(mode="json") | {"role": "submitter"}, + issue_body(UUID(contributor)).model_dump(mode="json") | {"role": "submitter"}, ) entered, release = asyncio.Event(), asyncio.Event() names = {side: f"cp08_{side}_{new_record_id().hex}" for side in ("claim", "issue")} @@ -89,8 +89,8 @@ async def observe_denial(authority, facts): async def issue(): async with AsyncSession(engines["issue"], expire_on_commit=False) as session: session.info["cp08_issue"] = True - prepared, resolved = await _issue_runtime(session, manager_id, link_id) - return await _issue_role( + prepared, resolved = await issue_runtime(session, manager_id, link_id) + return await issue_role( session, UUID(project["id"]), body, new_record_id(), resolved, prepared ) diff --git a/backend/tests/tasks/test_locked_context.py b/backend/tests/tasks/test_locked_context.py index 1a610a53c..ad96eecac 100644 --- a/backend/tests/tasks/test_locked_context.py +++ b/backend/tests/tasks/test_locked_context.py @@ -38,7 +38,7 @@ def test_locked_context_contracts(): for field in REFERENCE_FIELDS } summary = PostSubmitPolicyBodySummary(schema_version="1", default_checkers=(), - required_checkers=("check_acceptance_criteria_present",), + required_checkers=("test.project_quality",), warning_checkers=(), execution_checkers=(), blocking_severities=("high",)) for cls in MODELS: kwargs = {**values, **({SUMMARY: summary} if cls is ManagementTaskLockedContext else {})} diff --git a/backend/tests/tasks/test_payment_policy_migration.py b/backend/tests/tasks/test_payment_policy_migration.py index 030f41853..02ce3ff00 100644 --- a/backend/tests/tasks/test_payment_policy_migration.py +++ b/backend/tests/tasks/test_payment_policy_migration.py @@ -61,9 +61,9 @@ async def test_payment_cleanup_refuses_each_retained_fact_then_preserves_current await _drop_test_database_schema(isolated_database_env) await asyncio.to_thread(command.upgrade, _config(), "0022_submission_packet_custody") async with historical_material_fixture(tmp_path, isolated_database_env) as h: - async with h.engine.connect() as connection: + async with db_session.get_engine().connect() as connection: task = await connection.scalar(text("SELECT to_jsonb(t) FROM public.workstream_tasks t WHERE id=:id"), - {"id": h.request.task_id}) + {"id": h.request["task_id"]}) await connection.rollback() cases = [ ("submission_stamp", "retained Submission payment stamp"), @@ -82,15 +82,15 @@ async def test_payment_cleanup_refuses_each_retained_fact_then_preserves_current if case in {"submission_stamp", "locked_payment_policy_version"}: await connection.execute(text("UPDATE public.workstream_tasks " "SET locked_payment_policy_version=locked_guide_version WHERE id=:id"), - {"id": h.request.task_id}) + {"id": h.request["task_id"]}) if case == "submission_stamp": await connection.execute(text("UPDATE public.submissions " "SET locked_payment_policy_version=locked_guide_version WHERE id=:id"), - {"id": h.request.submission_id}) + {"id": h.request["submission_id"]}) if case in {"base_amount", "currency", "payout_type"}: value = {"base_amount": 0, "currency": "USD", "payout_type": "fixed"}[case] await connection.execute(text(f"UPDATE public.workstream_tasks SET {case}=:value WHERE id=:id"), - {"value": value, "id": h.request.task_id}) + {"value": value, "id": h.request["task_id"]}) if case.startswith("receipt:"): _, nested, field = case.split(":") payload = {field: None} if nested == "False" else {"task": {field: None}} @@ -100,9 +100,9 @@ async def test_payment_cleanup_refuses_each_retained_fact_then_preserves_current VALUES(:id,:actor,:action,:key,:digest,:task,'committed', :assignment,:contributor,:digest,CAST(:response AS jsonb),CURRENT_TIMESTAMP)"""), {"id": new_record_id(), "actor": task["assigned_to"], "key": new_record_id(), - "digest": "sha256:" + "a" * 64, "task": h.request.task_id, + "digest": "sha256:" + "a" * 64, "task": h.request["task_id"], "action": "task.claim" if nested == "True" else "project.task.create", - "assignment": h.request.assignment_id if nested == "True" else None, + "assignment": h.request["assignment_id"] if nested == "True" else None, "contributor": task["assigned_to"] if nested == "True" else None, "response": json.dumps(payload)}) # All pre-existing FKs must pass before the intended refusal. @@ -117,7 +117,7 @@ async def test_payment_cleanup_refuses_each_retained_fact_then_preserves_current before = await _snapshot(connection) # Full Alembic upgrade: current records exist, but obsolete facts do not. await asyncio.to_thread(command.upgrade, _config(), "head") - async with h.engine.connect() as connection: + async with db_session.get_engine().connect() as connection: assert await connection.scalar(text("SELECT to_regclass('public.payment_policies')")) is None for table, fields in (("workstream_tasks", FIELDS), ("submissions", (FIELDS[-1],))): actual = set(await connection.scalars(text("SELECT column_name FROM information_schema.columns " @@ -128,6 +128,15 @@ async def test_payment_cleanup_refuses_each_retained_fact_then_preserves_current f"SELECT to_jsonb(t) FROM public.{table} t ORDER BY to_jsonb(t)::text"))) removed = FIELDS if table == "workstream_tasks" else (FIELDS[-1],) if table == "submissions" else () expected = [{k: v for k, v in row.items() if k not in removed} for row in before[0][table]] + if table == "submissions": + added = {"selected_post_submit_policy_sha256", "selected_post_submit_policy_body"} + actual.sort(key=lambda row: row["id"]) + expected.sort(key=lambda row: row["id"]) + assert len(actual) == len(expected) + for upgraded, retained in zip(actual, expected, strict=True): + assert set(upgraded) == set(retained) | added + for column in added: + assert upgraded.pop(column) is None assert actual == expected, table assert ( await connection.scalar(text("SELECT version_num FROM public.alembic_version")) diff --git a/backend/tests/tasks/test_project_display.py b/backend/tests/tasks/test_project_display.py index ed123e861..94a506626 100644 --- a/backend/tests/tasks/test_project_display.py +++ b/backend/tests/tasks/test_project_display.py @@ -17,6 +17,7 @@ from app.modules.tasks.models import AuditEvent, WorkstreamTask from tests.authorization.task_locked_context.support import KINDS, path, grant_development_context_roles from tests.projects.locked_policy_fixtures import activated_context, frozen_request +from tests.checkers.external.support import post_registry_spec from tests.test_tasks import ( task_database_env as task_database_env, task_client as task_client, @@ -223,7 +224,7 @@ async def test_task_display_survives_guide_successor_for_contributor_and_manager await create_policy_bundle_for_guide( task_client, project["id"], successor.json()["id"], task_artifact_proposal().model_copy(update={"required_artifacts": ("v2-answer.md",)}), - post_submit_required_checkers=["check_acceptance_criteria_present"], + post_submit_registry_spec=post_registry_spec(), ) await seed_active_guide_for_downstream_test( db_session.get_session_factory(), project_id=project["id"], guide_id=successor.json()["id"], @@ -239,9 +240,8 @@ async def test_task_display_survives_guide_successor_for_contributor_and_manager PostSubmitCheckerPolicy.project_id == project["id"], PostSubmitCheckerPolicy.guide_id == active.id, )) - required = [entry["checker_id"] for entry in successor_policy.policy_body["entries"] - if entry["classification"] == "project_required"] - assert required == ["check_acceptance_criteria_present"] + required = [entry["registry"]["capability_id"] for entry in successor_policy.policy_body["entries"]] + assert required == [post_registry_spec().capability_id] assert required != original_summary["required_checkers"] effective = await session.scalar(select(EffectiveProjectSubmissionArtifactPolicy).where( EffectiveProjectSubmissionArtifactPolicy.guide_id == active.id, diff --git a/backend/tests/tasks/test_submission_lineage.py b/backend/tests/tasks/test_submission_lineage.py index a6d551fdd..dfd8833de 100644 --- a/backend/tests/tasks/test_submission_lineage.py +++ b/backend/tests/tasks/test_submission_lineage.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import replace -from contextlib import AsyncExitStack, asynccontextmanager +from contextlib import asynccontextmanager from types import SimpleNamespace from app.core.identifiers import new_record_id @@ -23,7 +23,7 @@ from app.modules.authorization.api import ActorIdentityFacts, ActorKind from app.modules.tasks.api import SubmissionCreationRequest from app.modules.tasks.models import AuditEvent, Submission, TaskAssignment, WorkstreamTask -from tests.pre_submit_test_helpers import approved_pre_submit_fixture +from tests.selected_policy_fixtures import approved_selected_policy_fixture from tests.submission_preparation_auth_helpers import install_submitter_grant from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test from tests.test_artifact_admission import ( @@ -33,7 +33,7 @@ _context, _seed_human_actor, ) -from tests.test_default_pre_submit_execution import _archive, _bytes +from tests.archive_fixtures import byte_source as _bytes, submission_archive as _archive from tests.tasks.submission_lineage_support import _seed_services, _verified_admission @@ -43,11 +43,16 @@ async def _prepared_packet(isolated_database_env, tmp_path): engine = create_async_engine(isolated_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) - settings = _settings(tmp_path, maximum_bytes=1024 * 1024) + settings = _settings(tmp_path, maximum_bytes=1024 * 1024).model_copy( + update={"external_checker_service_socket": tmp_path / "checker.sock", + "external_checker_material_root": tmp_path / "scratch"} + ) namespace = _namespace(settings) bootstrap, store = _local_store(settings, namespace) try: - plan, policy = await approved_pre_submit_fixture(factory, namespace, guide_version="v1") + plan, policy = await approved_selected_policy_fixture( + factory, namespace, guide_version="v1" + ) context = _context() await _seed_services(factory) task_id, assignment_id = new_record_id(), new_record_id() @@ -222,10 +227,10 @@ async def _assert_bound_packet_immutable(h, created): await session.execute(text(f"UPDATE public.submissions SET {update} WHERE id=:id"), {"id": created.submission_id, "foreign": new_record_id()}) for statement, message in ( - ("UPDATE public.pre_submit_evidence_sets SET packet_sha256='sha256:' || repeat('0',64) " - "WHERE id=(SELECT pre_submit_evidence_set_id FROM public.submission_bundle_admissions WHERE id=:id)", - "pre_submit_evidence_sets rows are immutable"), - ("UPDATE public.submission_bundle_admissions SET pre_submit_evidence_set_id=:foreign WHERE id=:id", + ("UPDATE public.selected_pre_submit_runs SET request_digest='sha256:' || repeat('0',64) " + "WHERE id=(SELECT selected_pre_submit_run_id FROM public.submission_bundle_admissions WHERE id=:id)", + "selected PRE request custody is immutable"), + ("UPDATE public.submission_bundle_admissions SET selected_pre_submit_run_id=:foreign WHERE id=:id", "submission bundle admission lineage is immutable"), ): with pytest.raises(DBAPIError, match=message): @@ -247,7 +252,8 @@ async def test_packet_custody_upgrade_preserves_retained_submission( import asyncpg from alembic import command from app.db import session as db_session - from tests.migration_fixtures import _config, current_art_attempt_seed_schema + from tests.historical_submission_fixtures import historical_material_fixture + from tests.migration_fixtures import _config with migration_lock(): await db_session.dispose_engine() @@ -257,26 +263,23 @@ async def test_packet_custody_upgrade_preserves_retained_submission( finally: await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0021_submission_manifest") - async with AsyncExitStack() as stack: - async with current_art_attempt_seed_schema(isolated_database_env): - h = await stack.enter_async_context(_prepared_packet(isolated_database_env, tmp_path)) - from tests.historical_submission_fixtures import write_historical_submission - created = await write_historical_submission(h.factory, h.context, h.creation) - # The predecessor allowed this mismatch. Upgrade must neither invent - # evidence nor rewrite/delete retained text, even when inconsistent. - async with h.factory.begin() as session: - await session.execute(text( - "UPDATE public.submissions SET summary = summary || ' Historical.' WHERE id=:id" - ), {"id": created.submission_id}) - before = await session.scalar(text( - "SELECT to_jsonb(s) FROM public.submissions s WHERE id=:id" - ), {"id": created.submission_id}) + async with historical_material_fixture(tmp_path, isolated_database_env) as h: + submission_id = h.request["submission_id"] + # The predecessor allowed this mismatch. Upgrade must neither invent + # evidence nor rewrite/delete retained text, even when inconsistent. + async with h.factory.begin() as session: + await session.execute(text( + "UPDATE public.submissions SET summary = summary || ' Historical.' WHERE id=:id" + ), {"id": submission_id}) + before = await session.scalar(text( + "SELECT to_jsonb(s) FROM public.submissions s WHERE id=:id" + ), {"id": submission_id}) # Bind this preservation proof to the packet-custody migration. await asyncio.to_thread(command.upgrade, _config(), "0022_submission_packet_custody") async with h.factory() as session: after = await session.scalar(text( "SELECT to_jsonb(s) FROM public.submissions s WHERE id=:id" - ), {"id": created.submission_id}) + ), {"id": submission_id}) assert after == before assert await session.scalar(text( "SELECT count(*) FROM pg_catalog.pg_trigger WHERE tgname='submission_packet_custody'" diff --git a/backend/tests/test_agent_runtime.py b/backend/tests/test_agent_runtime.py index 579c0bba7..36686c485 100644 --- a/backend/tests/test_agent_runtime.py +++ b/backend/tests/test_agent_runtime.py @@ -475,7 +475,9 @@ def test_sdk_output_schema_has_one_intake_configuration_and_separate_post_parame pre = schema["$defs"]["PreSubmissionBindingProposal"] post = schema["$defs"]["PostSubmissionBindingProposal"] assert pre["additionalProperties"] is False - assert "parameters" not in pre["properties"] + assert "parameters" in pre["properties"] + assert pre["properties"]["parameters"]["maxItems"] == 50 + assert post["properties"]["parameters"]["maxItems"] == 50 assert "parameters" in post["properties"] parameter_values = schema["$defs"]["CapabilityParameter"]["properties"]["value"]["anyOf"] array = next(item for item in parameter_values if item.get("type") == "array") diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index c71032a89..2f66f6e10 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -79,7 +79,7 @@ def test_v01_graph_has_one_root_and_head() -> None: script = ScriptDirectory.from_config(config) revisions = list(script.walk_revisions()) - assert [revision.revision for revision in revisions] == [HEAD_REVISION, "0034_checker_replacement", "0033_contribution_reads", "0032_checker_remediation", "0031_task_import_source", "0030_routing_outcomes", "0029_external_checker_registry", "0028_lifecycle_transitions", "0027_markdown_guide_media", "0026_task_guide_read", "0025_submission_dispatch", "0024_require_second_review_false", "0023_remove_task_payment_policy", "0022_submission_packet_custody", "0021_submission_manifest", "0020_review_admission_lock_order", "0019_submitter_awards", "0018_task_routing_request", "0017_acceptance_source_contracts", "0016_review_lifecycle_fence", "0015_contribution_awards", "0014_final_acceptance", "0013_review_source", "0012_review_packet", "0011_task_routing_source", "0010_post_submit_authority", "0009_checker_material_lineage", "0008_checker_execution", "0007_checker_output_custody", "0006_history_read_authority", "0005_task_evidence_authority", "0004_task_context_authority", "0003_task_read_authority", "0002_task_queue_authority", BASELINE_REVISION] + assert [revision.revision for revision in revisions] == [HEAD_REVISION, "0035_checker_retry", "0034_checker_replacement", "0033_contribution_reads", "0032_checker_remediation", "0031_task_import_source", "0030_routing_outcomes", "0029_external_checker_registry", "0028_lifecycle_transitions", "0027_markdown_guide_media", "0026_task_guide_read", "0025_submission_dispatch", "0024_require_second_review_false", "0023_remove_task_payment_policy", "0022_submission_packet_custody", "0021_submission_manifest", "0020_review_admission_lock_order", "0019_submitter_awards", "0018_task_routing_request", "0017_acceptance_source_contracts", "0016_review_lifecycle_fence", "0015_contribution_awards", "0014_final_acceptance", "0013_review_source", "0012_review_packet", "0011_task_routing_source", "0010_post_submit_authority", "0009_checker_material_lineage", "0008_checker_execution", "0007_checker_output_custody", "0006_history_read_authority", "0005_task_evidence_authority", "0004_task_context_authority", "0003_task_read_authority", "0002_task_queue_authority", BASELINE_REVISION] assert revisions[-1].down_revision is None assert script.get_heads() == [HEAD_REVISION] @@ -204,6 +204,135 @@ def test_repeated_upgrade_head_preserves_current_database( assert after == before +def test_current_head_installs_selected_only_checker_custody( + isolated_database_env: str, migration_lock +) -> None: + """Current writers reject missing schemas and contain no executable catalogue tail.""" + config = _alembic_config() + with migration_lock(): + asyncio.run( + _execute(isolated_database_env, "drop schema public cascade; create schema public") + ) + command.upgrade(config, HEAD_REVISION) + + async def installed_functions() -> dict[str, str]: + from tests.conftest import _assert_canonical_test_schema + + connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) + transaction = connection.transaction() + await transaction.start() + try: + await connection.execute("set local check_function_bodies = true") + installed = await connection.fetch( + "select p.oid,p.oid::regprocedure::text as identity " + "from pg_proc p join pg_namespace n on n.oid=p.pronamespace " + "join pg_language l on l.oid=p.prolang " + "where n.nspname='public' and l.lanname='plpgsql' order by p.oid" + ) + assert installed + for row in installed: + try: + await connection.fetchval( + "select pg_catalog.plpgsql_validator($1::oid)", row["oid"] + ) + except asyncpg.PostgresError as error: + raise AssertionError( + f"installed function does not compile: {row['identity']}" + ) from error + rows = await connection.fetch( + "select p.proname,pg_get_functiondef(p.oid) as definition " + "from pg_proc p join pg_namespace n on n.oid=p.pronamespace " + "where n.nspname='public' and p.proname=any($1::text[])", + [ + "protect_checker_run_custody", + "protect_checker_result_custody", + "validate_checker_terminal_custody", + "submission_dispatch_valid", + "external_checker_locked_policies_valid", + "require_guide_proposal_approval", + "require_post_policy_operation", + "guard_pre_submit_attempt", + "guard_pre_submit_evidence_set_creation", + "guard_pre_submit_evidence_result_membership", + "require_pre_submit_attempt_evidence", + "require_pre_submit_result_reconstruction", + ], + ) + await _assert_canonical_test_schema(connection) + return {row["proname"]: row["definition"] for row in rows} + finally: + await transaction.rollback() + await connection.close() + + definitions = asyncio.run(installed_functions()) + assert set(definitions) == { + "protect_checker_run_custody", + "protect_checker_result_custody", + "validate_checker_terminal_custody", + "submission_dispatch_valid", + "external_checker_locked_policies_valid", + "require_guide_proposal_approval", + "require_post_policy_operation", + "guard_pre_submit_attempt", + "guard_pre_submit_evidence_set_creation", + "guard_pre_submit_evidence_result_membership", + "require_pre_submit_attempt_evidence", + "require_pre_submit_result_reconstruction", + } + for name in ( + "protect_checker_run_custody", + "protect_checker_result_custody", + "validate_checker_terminal_custody", + ): + assert "schema_version' IS DISTINCT FROM" in definitions[name] + assert "check_submission_packet" not in definitions["protect_checker_result_custody"] + assert "member_results' IS DISTINCT FROM members" not in definitions[ + "validate_checker_terminal_custody" + ] + assert "workstream.selected_post_submit_request.v1" in definitions[ + "submission_dispatch_valid" + ] + assert "selected_pre_submit_run_id IS NOT NULL" in definitions[ + "submission_dispatch_valid" + ] + assert "external_plan" not in definitions["require_guide_proposal_approval"] + assert "->'catalogue'" not in definitions["require_guide_proposal_approval"] + assert "catalogue_manifest_sha256" not in definitions["require_post_policy_operation"] + assert "external_post_submit_policy.v1" in definitions[ + "require_post_policy_operation" + ] + assert "checker terminal selected policy differs" in definitions[ + "validate_checker_terminal_custody" + ] + assert "result_registry_entry_id" in definitions[ + "validate_checker_terminal_custody" + ] + assert "platform_manifest_digest" in definitions[ + "validate_checker_terminal_custody" + ] + assert "is distinct from 'external_checker_policies.v1'" in definitions[ + "external_checker_locked_policies_valid" + ].lower() + retired = { + "guard_pre_submit_attempt": "retained pre-submit attempts are immutable", + "guard_pre_submit_evidence_set_creation": ( + "retained pre-submit evidence is immutable" + ), + "guard_pre_submit_evidence_result_membership": ( + "retained pre-submit evidence results are immutable" + ), + "require_pre_submit_attempt_evidence": ( + "retained pre-submit evidence is immutable" + ), + "require_pre_submit_result_reconstruction": ( + "retained pre-submit evidence results are immutable" + ), + } + for name, message in retired.items(): + assert message in definitions[name] + assert " select " not in definitions[name].lower() + + def test_current_head_installs_submission_lineage_contract( isolated_database_env: str, migration_lock ) -> None: diff --git a/backend/tests/test_approved_guide_intake.py b/backend/tests/test_approved_guide_intake.py deleted file mode 100644 index 7b972c65a..000000000 --- a/backend/tests/test_approved_guide_intake.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Final intake revalidation uses exact activated owner facts, not current selectors.""" - -from app.adapters.checkers import submission_evaluation_content -from app.adapters.tasks import task_submission_context_port - -from dataclasses import replace -from types import SimpleNamespace - -import pytest -from sqlalchemy import func, select, text - -from app.core.config import Settings -from app.core.hashing import canonical_json_hash -from app.modules.artifacts.models import ArtifactPutAttempt, PreSubmitEvidenceSet, SubmissionBundleDurableIntent -from app.modules.artifacts.schemas import SubmissionBundleArtifactAdmissionRequest -from app.modules.artifacts.service import ArtifactAdmissionRelationshipError, ArtifactAdmissionService -from app.modules.artifacts.submission_custody import SubmissionBundlePreparedCustody -from app.modules.artifacts.service import ArtifactStorageNamespaceSpec -from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository -from app.modules.projects.models import ProjectGuide -from app.modules.projects.guide_activation.custody import load_guide_activation -from tests.pre_submit_test_helpers import execute_evidence_workflow -from tests.artifact_store_helpers import artifact_admission_limit_settings -from tests.test_pre_submit_attempt_recovery import _harness - - -async def _checked(harness, session): - calls = [] - workflow = harness.workflow(session, calls) - result = await execute_evidence_workflow( - workflow, harness.request, preparation_request=harness.preparation_request, - ) - assert calls == [1] and result.pass_capability is not None - custody = SubmissionBundlePreparedCustody._from_live_preparation( - prepared=harness.request.prepared_artifact, capability=result.pass_capability, - ) - return SubmissionBundleArtifactAdmissionRequest( - pre_submit_evidence_set_id=result.evidence.evidence_set_id, custody=custody, - replay_durable_intent_id=None, - ) - - -def _admission(session, tmp_path): - namespace = ArtifactStorageNamespaceSpec( - backend="local", adapter="local", provider_profile="test", - namespace_descriptor={"test": "submission-bundle"}, - namespace_fingerprint=canonical_json_hash({"test": "submission-bundle"}), - ) - return ArtifactAdmissionService(session, Settings(_env_file=None, environment="test", artifact_store_backend="local", - artifact_local_root=tmp_path / "durable", artifact_scratch_root=tmp_path / "scratch", - artifact_scratch_minimum_free_bytes=0, - **artifact_admission_limit_settings(1024 * 1024)), namespace) - - -async def _admit(session, request, authority, tmp_path, *, task_contexts=None): - # This is the real durable-admission writer, including final AUTH and custody. - return await _admission(session, tmp_path).admit( - request, submission_prepared_authorization=authority, - submission_task_contexts=task_contexts or task_submission_context_port(session), - submission_project_contexts=ProjectLockedPolicyRepository(session), - existing_transaction=True, - ) - - -async def _assert_intent(factory, request, admission, lineage): - # Read committed facts independently of the writer's session. - async with factory() as session: - intent, evidence, attempt = (await session.execute( - select(SubmissionBundleDurableIntent, PreSubmitEvidenceSet, ArtifactPutAttempt) - .join(PreSubmitEvidenceSet, PreSubmitEvidenceSet.id == SubmissionBundleDurableIntent.pre_submit_evidence_set_id) - .join(ArtifactPutAttempt, ArtifactPutAttempt.id == SubmissionBundleDurableIntent.put_attempt_id) - )).one() - assert intent.put_attempt_id == str(admission.attempt_id) == attempt.id - assert intent.pre_submit_evidence_set_id == str(request.pre_submit_evidence_set_id) == evidence.id - assert attempt.task_id == evidence.task_id - assert attempt.project_id == evidence.project_id == str(lineage.project_id) - assert evidence.guide_id == str(lineage.guide_id) - assert evidence.source_snapshot_id == str(lineage.source_snapshot_id) - assert evidence.effective_policy_id == str(lineage.effective_policy_id) - assert evidence.pre_submit_policy_id == str(lineage.pre_submit_policy_id) - assert evidence.locked_checker_policy_sha256 == lineage.pre_submit_policy_bundle_hash - - -async def test_final_intake_keeps_original_guide_after_successor(tmp_path, isolated_database_env): - from tests.projects.guide_activation.test_successor import successor_command - from tests.projects.guide_activation.pg_support import publish_policy - from tests.authorization.guide_activation.pg_support import activate - - harness = await _harness(tmp_path, isolated_database_env) - try: - async with harness.factory() as session: - request = await _checked(harness, session) - async with session.begin(): - lineage = harness.request.effective_plan.lineage - guide = await session.get(ProjectGuide, str(lineage.guide_id)) - first = await load_guide_activation(session, guide) - target = first.command.target.proposal - from app.modules.actors.models import ActorIdentityLink - from app.modules.authorization.models import AdminRoleGrant - from app.modules.authorization.api import ActorIdentityFacts, ActorKind - from uuid import UUID - async with harness.factory() as manager_session: - grant_row, link = (await manager_session.execute( - select(AdminRoleGrant, ActorIdentityLink) - .join(ActorIdentityLink, ActorIdentityLink.actor_profile_id == AdminRoleGrant.target_actor_profile_id) - .where(AdminRoleGrant.scope_project_id == str(target.project_id), - AdminRoleGrant.role == "project_manager", AdminRoleGrant.status == "active", - AdminRoleGrant.target_actor_profile_id != str(harness.actor_id), - ActorIdentityLink.status == "active") - .order_by(AdminRoleGrant.id, ActorIdentityLink.id).limit(1) - )).one() - actor = ActorIdentityFacts(UUID(str(grant_row.target_actor_profile_id)), UUID(str(link.id)), ActorKind.HUMAN) - grant = UUID(str(grant_row.id)) - _, policy = await publish_policy(harness.factory, target.project_id) - successor = await successor_command( - harness.factory, first.command, actor, grant, policy, - ) - successor = successor.model_copy(update={ - "expected_previous_active_guide_id": first.command.target.proposal.guide_id, - "expected_previous_active_guide_generation": first.activation_generation, - }) - next_receipt = await activate(harness.factory, actor, successor) - assert next_receipt.command.contribution_policy_version_id != first.command.contribution_policy_version_id - async with session.begin(): - authority = harness.contributor_authority(session) - await authority.revalidate(request=harness.preparation_request, project_id=lineage.project_id) - admission = await _admit(session, request, authority, tmp_path) - guide = await session.get(ProjectGuide, str(lineage.guide_id), populate_existing=True) - assert guide.status == "superseded" - await _assert_intent(harness.factory, request, admission, lineage) - finally: - await harness.close() - - -@pytest.mark.parametrize("drift", ["archived_project", "foreign_contribution_version"]) -async def test_final_intake_rejects_invalid_owner_context(tmp_path, isolated_database_env, drift): - harness = await _harness(tmp_path, isolated_database_env) - try: - async with harness.factory() as session: - request = await _checked(harness, session) - lineage = harness.request.effective_plan.lineage - authority = harness.contributor_authority(session) - async with session.begin(): - await authority.revalidate(request=harness.preparation_request, project_id=lineage.project_id) - if drift == "archived_project": - await session.execute(text("UPDATE projects SET status='archived' WHERE id=:id"), - {"id": str(lineage.project_id)}) - await session.flush() - with pytest.raises(ArtifactAdmissionRelationshipError, match="pre_submit_locked_context_changed"): - await _admit(session, request, authority, tmp_path) - else: - # Substitute a same-shaped owner result; database guards are not disabled. - from app.core.identifiers import new_record_id - real = task_submission_context_port(session) - from app.modules.tasks.api import TaskSubmissionContextRequest - facts = await real.lock_submission_context(TaskSubmissionContextRequest( - task_id=harness.request.task_id, assignment_id=harness.request.assignment_id, - contributor_id=harness.actor_id, predecessor_submission_id=None, - )) - foreign = new_record_id() - substituted = replace(facts, - submitter_contribution_policy_version_id=foreign, - locked_policy=facts.locked_policy.model_copy(update={ - "locked_contribution_policy_version_id": foreign, - }), - locked_project_context=replace(facts.locked_project_context, - locked_contribution_policy_version_id=foreign)) - async def wrong_context(_): - return substituted - with pytest.raises(ArtifactAdmissionRelationshipError, match="locked context changed"): - await _admit(session, request, authority, tmp_path, - task_contexts=SimpleNamespace(lock_submission_context=wrong_context)) - assert await session.scalar(select(func.count()).select_from(SubmissionBundleDurableIntent)) == 0 - await session.rollback() - async with session.begin(): - # The same passing custody remains valid after the rejected transaction rolls back. - await authority.revalidate(request=harness.preparation_request, project_id=lineage.project_id) - control = await _admit(session, request, authority, tmp_path) - await _assert_intent(harness.factory, request, control, lineage) - finally: - await harness.close() - - -async def test_command_holds_authorized_context_before_final_handoff(tmp_path, isolated_database_env): - """Observe real row locks at the final handoff of the actual async command.""" - from contextlib import asynccontextmanager - from sqlalchemy.exc import DBAPIError - from app.adapters.artifacts import CheckerPhaseService - from app.modules.artifacts.authorization import PreparedPreSubmitMaterializationAuthorization - from app.modules.artifacts.submission_admission import ( - PreparedSubmissionBundlePreparationCommand, SubmissionBundlePreparationRuntime, - SubmissionBundleDurablePutService, SubmissionBundleDurablePutResult, - ) - from tests.checkers.execution.support import forbidden_post_submission - from tests.authorization.test_pre_submit_attempt_authority import _seed_materializer - from tests.test_default_pre_submit_execution import _archive, _bytes - - harness = await _harness(tmp_path, isolated_database_env) - try: - await _seed_materializer(harness.factory) - await harness.request.prepared_artifact.close() - async with harness.factory() as session: - contributor = harness.contributor_authority(session) - materializer = PreparedPreSubmitMaterializationAuthorization( - session, request_id=harness.preparation_request.request_id, - correlation_id=harness.preparation_request.correlation_id, - ) - calls = [] - workflow = harness.workflow(session, calls, preparation_authorization=contributor) - workflow._materialization._authorization = materializer - admission = _admission(session, tmp_path) - durable = SubmissionBundleDurablePutService( - session=session, admission=admission, storage=object(), authorization=contributor, - task_contexts=task_submission_context_port(session), project_contexts=ProjectLockedPolicyRepository(session), - ) - observed = [] - async def final_handoff(request): - grant_id = (await session.execute(text( - "SELECT id FROM project_role_grants WHERE actor_profile_id=:actor " - "AND project_id=:project AND role='submitter' AND status='active'" - ), {"actor": str(harness.actor_id), - "project": str(harness.request.effective_plan.lineage.project_id)})).scalar_one() - for table, record_id in ( - ("workstream_tasks", harness.request.task_id), - ("task_assignments", harness.request.assignment_id), - ("actor_profiles", harness.actor_id), - ("actor_identity_links", harness.identity_link_id), - ("projects", harness.request.effective_plan.lineage.project_id), - ("project_role_grants", grant_id), - ): - async with harness.factory() as probe: - with pytest.raises(DBAPIError, match="lock timeout"): - async with probe.begin(): - await probe.execute(text("SET LOCAL lock_timeout='150ms'")) - await probe.execute(text(f"SELECT id FROM {table} WHERE id=:id FOR UPDATE"), - {"id": str(record_id)}) - result = await durable.admit_in_transaction(request) - observed.append(result[2].attempt_id) - return result - - async def stop_before_provider(prepared, evidence_id, admission_result): - # Byte execution ends at committed durable intent in this proof. - await prepared.close() - return SubmissionBundleDurablePutResult( - put_attempt_id=admission_result.attempt_id, - pre_submit_evidence_set_id=evidence_id, - operation_identity=admission_result.operation_identity, - status="prepared", replayed=False, admission_id=None, - ) - - @asynccontextmanager - async def runtime(): - yield SubmissionBundlePreparationRuntime( - evaluation_content=submission_evaluation_content, - preparation=harness.preparation, inspector=harness.inspector, - catalogue=harness.catalogue, materialization=workflow._materialization, - evidence=workflow, checker_service=CheckerPhaseService( - pre_submission=workflow, post_submission=forbidden_post_submission()), - durable_put=SimpleNamespace(admit_in_transaction=final_handoff, - publish_after_commit=stop_before_provider), - ) - - command = PreparedSubmissionBundlePreparationCommand( - session=session, authority=contributor, task_contexts=task_submission_context_port(session), - project_contexts=ProjectLockedPolicyRepository(session), runtime_factory=runtime, - ) - try: - result = await command.prepare(replace(harness.preparation_request, - byte_source=_bytes(_archive(evidence_path=harness.evidence_path)))) - assert observed == [result.put_attempt_id] and calls == [1] - async with harness.factory() as assertion: - intent = (await assertion.scalars(select(SubmissionBundleDurableIntent))).one() - assert intent.put_attempt_id == str(result.put_attempt_id) - finally: - materializer.close() - finally: - await harness.close() diff --git a/backend/tests/test_artifact_admission_digest.py b/backend/tests/test_artifact_admission_digest.py index 5716d580f..5870248d9 100644 --- a/backend/tests/test_artifact_admission_digest.py +++ b/backend/tests/test_artifact_admission_digest.py @@ -79,6 +79,7 @@ async def test_guide_replay_preserves_canonical_producer_digest( "checker_run_id": None, "logical_role": None, "pre_submit_evidence_set_id": None, + "selected_pre_submit_run_id": None, "sha256": source.commitment.sha256, "byte_count": source.commitment.byte_count, "media_type": source.commitment.media_type, diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index 9f5783cc9..535ff9376 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -27,6 +27,7 @@ CHECKER_MATERIALIZATION_API = ( APP_ROOT / "modules" / "checkers" / "api" / "materialization.py" ) +CHECKER_MATERIAL_API = APP_ROOT / "modules" / "checkers" / "api" / "material.py" RETIRED_ARTIFACT_MATERIALIZATION_API = ( APP_ROOT / "modules" / "artifacts" / "api" / "submission_materialization.py" ) @@ -281,7 +282,19 @@ def test_project_domain_does_not_query_artifact_persistence_models() -> None: assert violations == [] -def test_concrete_adapter_construction_has_one_composition_path() -> None: +@pytest.fixture(scope="module") +def adapter_composition_inventory() -> tuple[tuple[Path, frozenset[str], tuple[str, ...]], ...]: + """Freeze complete-source imports and ordered calls, preserving multiplicity.""" + inventory = [] + for path in _python_files(APP_ROOT): + imports, calls = imported_symbols_and_calls(_tree(path)) + inventory.append((path, frozenset(imports), tuple(calls))) + return tuple(inventory) + + +def _assert_concrete_adapter_composition( + inventory: tuple[tuple[Path, frozenset[str], tuple[str, ...]], ...], +) -> None: factory_calls: list[Path] = [] adapter_calls: list[Path] = [] concrete_imports: list[Path] = [] @@ -289,8 +302,7 @@ def test_concrete_adapter_construction_has_one_composition_path() -> None: agent_calls: list[Path] = [] observability_imports: list[Path] = [] observability_calls: list[Path] = [] - for path in _python_files(APP_ROOT): - imports, calls = imported_symbols_and_calls(_tree(path)) + for path, imports, calls in inventory: if imports & CONCRETE_ADAPTER_MODULES: concrete_imports.append(path) if AGENT_ADAPTER_MODULE in imports: @@ -329,6 +341,10 @@ def test_concrete_adapter_construction_has_one_composition_path() -> None: assert observability_calls == [OBSERVABILITY_COMPOSITION_ROOT] * 3 +def test_concrete_adapter_construction_has_one_composition_path(adapter_composition_inventory) -> None: + _assert_concrete_adapter_composition(adapter_composition_inventory) + + @pytest.mark.parametrize("source", [ "from app.interfaces.external_services import ExternalServiceAdapterFactory as factory\nfactory[object]('extra')\n", "from app.interfaces.external_services import ExternalServiceAdapterFactory\nfactory: object = ExternalServiceAdapterFactory\nfactory[object]('extra')\n", @@ -336,15 +352,16 @@ def test_concrete_adapter_construction_has_one_composition_path() -> None: "from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime as runtime\nruntime(configuration)\n", "import app.adapters.project_agents.openai_agent_sdk as sdk\nsdk.OpenAIAgentSdkProjectGuideRuntime(configuration)\n", ]) -def test_composition_proof_rejects_aliased_factories_and_concrete_runtimes(tmp_path, monkeypatch, source): - import sys - - paths = _python_files(APP_ROOT) +def test_composition_proof_rejects_aliased_factories_and_concrete_runtimes( + tmp_path, adapter_composition_inventory, source, +): + _assert_concrete_adapter_composition(adapter_composition_inventory) injected = tmp_path / "extra.py" injected.write_text(source) - monkeypatch.setattr(sys.modules[__name__], "_python_files", lambda *_: (*paths, injected)) + imports, calls = imported_symbols_and_calls(_tree(injected)) + injected_inventory = (*adapter_composition_inventory, (injected, frozenset(imports), tuple(calls))) with pytest.raises(AssertionError): - test_concrete_adapter_construction_has_one_composition_path() + _assert_concrete_adapter_composition(injected_inventory) def test_s3_adapter_exposes_only_required_immutable_object_operations() -> None: @@ -616,12 +633,18 @@ def test_checker_materialization_contract_has_one_canonical_consumer_owner() -> "MaterializationFacts", "PreparedMaterialization", "MaterializationAuthorityPort", "PostSubmissionMaterializationUnavailable", "PostSubmissionMaterializationFailure", - "SubmissionMaterialEntry", + "ExternalCheckerGrantReference", "SubmissionMaterialView", "PostSubmissionMaterialConsumer", "PostSubmissionMaterializationResult", "PostSubmissionMaterializationPort", } + material_classes = { + node.name: node + for node in _tree(CHECKER_MATERIAL_API).body + if isinstance(node, ast.ClassDef) + } + assert set(material_classes) == {"SubmissionMaterialEntry"} assert { node.name for node in classes["PostSubmissionMaterializationPort"].body @@ -631,8 +654,28 @@ def test_checker_materialization_contract_has_one_canonical_consumer_owner() -> assert "Materialization" not in artifact_api.read_text(encoding="utf-8") -def test_checker_custody_ports_have_exact_owner_and_art_consumers() -> None: - """Allow only named CHECKERS consumers and ART implementations of custody ports.""" +def _imported_modules(path: Path, tree: ast.Module) -> frozenset[str]: + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + package = ".".join(path.parent.relative_to(BACKEND_ROOT).parts) + module = resolve_name("." * node.level + (node.module or ""), package) + modules.add(module) + modules.update(f"{module}.{alias.name}" for alias in node.names) + elif isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + return frozenset(modules) + + +@pytest.fixture(scope="module") +def checker_custody_import_inventory() -> tuple[tuple[Path, frozenset[str]], ...]: + """Freeze imports from every actual APP source without sharing mutable ASTs.""" + return tuple((path, _imported_modules(path, _tree(path))) for path in _python_files(APP_ROOT)) + + +def _assert_checker_custody_consumers( + inventory: tuple[tuple[Path, frozenset[str]], ...], +) -> None: expected = { "app.modules.checkers.api.output_custody": { "app/adapters/artifacts/__init__.py", @@ -646,23 +689,17 @@ def test_checker_custody_ports_have_exact_owner_and_art_consumers() -> None: "app.modules.checkers.api.materialization": { "app/modules/authorization/domain/post_submit.py", "app/modules/authorization/post_submit_authorization.py", - "app/adapters/artifacts/__init__.py", - "app/modules/artifacts/post_submit_materialization.py", - "app/modules/artifacts/post_submit_selection.py", - "app/modules/checkers/execution.py", - }, + "app/adapters/artifacts/__init__.py", + "app/modules/artifacts/post_submit_materialization.py", + "app/modules/artifacts/post_submit_selection.py", + "app/modules/artifacts/selected_pre_submit_admission.py", + "app/modules/checkers/default_checker.py", + "app/modules/checkers/execution.py", + "app/modules/checkers/selected_pre_submit_execution.py", + }, } actual = {module: set() for module in expected} - for path in _python_files(APP_ROOT): - modules: set[str] = set() - for node in ast.walk(_tree(path)): - if isinstance(node, ast.ImportFrom): - package = ".".join(path.parent.relative_to(BACKEND_ROOT).parts) - module = resolve_name("." * node.level + (node.module or ""), package) - modules.add(module) - modules.update(f"{module}.{alias.name}" for alias in node.names) - elif isinstance(node, ast.Import): - modules.update(alias.name for alias in node.names) + for path, modules in inventory: for module in modules & expected.keys(): actual[module].add(path.relative_to(BACKEND_ROOT).as_posix()) assert actual == expected @@ -685,12 +722,19 @@ def test_checker_custody_ports_have_exact_owner_and_art_consumers() -> None: } +def test_checker_custody_ports_have_exact_owner_and_art_consumers( + checker_custody_import_inventory, +) -> None: + """Allow only named CHECKERS consumers and ART implementations of custody ports.""" + _assert_checker_custody_consumers(checker_custody_import_inventory) + + @pytest.mark.parametrize("port", ["output_custody", "materialization"]) @pytest.mark.parametrize("form", ["symbol", "module", "parent", "relative", "relative_parent"]) -def test_checker_custody_inventory_rejects_unregistered_import(monkeypatch, port, form): +def test_checker_custody_inventory_rejects_unregistered_import( + checker_custody_import_inventory, port, form, +): """Alternate import spelling must not conceal an additional API consumer.""" - import sys - statement = { "symbol": f"from app.modules.checkers.api.{port} import AnyContract", "module": f"import app.modules.checkers.api.{port} as custody", @@ -698,17 +742,18 @@ def test_checker_custody_inventory_rejects_unregistered_import(monkeypatch, port "relative": f"from .modules.checkers.api.{port} import AnyContract", "relative_parent": f"from .modules.checkers.api import {port} as custody", }[form] - original = _tree - - def injected(path): - tree = original(path) - if path == APP_ROOT / "main.py": - tree.body.extend(ast.parse(statement).body) - return tree - - monkeypatch.setattr(sys.modules[__name__], "_tree", injected) + _assert_checker_custody_consumers(checker_custody_import_inventory) + main = APP_ROOT / "main.py" + assert main in {path for path, _ in checker_custody_import_inventory} + injected_tree = _tree(main) + injected_tree.body.extend(ast.parse(statement).body) + injected_modules = _imported_modules(main, injected_tree) + injected_inventory = tuple( + (path, injected_modules if path == main else modules) + for path, modules in checker_custody_import_inventory + ) with pytest.raises(AssertionError): - test_checker_custody_ports_have_exact_owner_and_art_consumers() + _assert_checker_custody_consumers(injected_inventory) def test_submission_preparation_http_request_never_carries_prepared_authority() -> None: diff --git a/backend/tests/test_artifact_bindings.py b/backend/tests/test_artifact_bindings.py index b4f6d2f39..20ceea031 100644 --- a/backend/tests/test_artifact_bindings.py +++ b/backend/tests/test_artifact_bindings.py @@ -7,6 +7,8 @@ from datetime import UTC, datetime from dataclasses import replace from app.modules.checkers.api import SubmissionPacketView +from app.core.hashing import canonical_json_hash +from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy from types import SimpleNamespace from unittest.mock import AsyncMock, Mock from uuid import UUID @@ -65,7 +67,10 @@ def _lineage(request: SubmissionAdmissionConsumptionRequest): refs = context.locked_project_context manifest = _manifest(_archive([("answer.txt", b"verified")])) content_id = str(new_record_id()) - evidence_id = str(new_record_id()) + policy_body = {"schema_version": "external_post_submit_policy.v1", "entries": []} + policy = LockedExternalPostSubmitPolicy.model_validate({ + **policy_body, "policy_sha256": canonical_json_hash(policy_body), + }) admission = SimpleNamespace( id=str(request.admission_id), status="ready", @@ -76,12 +81,19 @@ def _lineage(request: SubmissionAdmissionConsumptionRequest): predecessor_submission_id=None, predecessor_submission_version=None, checker_remediation_manifest_id=None, - pre_submit_evidence_set_id=evidence_id, + pre_submit_evidence_set_id=None, + selected_pre_submit_run_id=str(new_record_id()), identity_link_id=str(new_record_id()), artifact_content_id=content_id, locked_policy_context_hash=_sha("5"), semantic_manifest_id=str(new_record_id()), semantic_manifest_sha256=manifest.sha256, + semantic_manifest_body=manifest.as_dict(), + packet_sha256=request.packet_sha256, + effective_plan_sha256=_sha("6"), + selected_rules_sha256=_sha("7"), + selected_post_submit_policy_body=policy.model_dump(mode="json"), + selected_post_submit_policy_sha256=policy.policy_sha256, archive_sha256=_sha("4"), archive_byte_count=9, consumed_at=None, @@ -89,41 +101,21 @@ def _lineage(request: SubmissionAdmissionConsumptionRequest): consumed_by_submission_version=None, stale_at=None, stale_reason=None, - ) - evidence = SimpleNamespace( - packet_sha256=request.packet_sha256, - id=evidence_id, - actor_profile_id=admission.actor_profile_id, - identity_link_id=admission.identity_link_id, - project_id=admission.project_id, - task_id=admission.task_id, - assignment_id=admission.assignment_id, - predecessor_submission_id=admission.predecessor_submission_id, - predecessor_submission_version=admission.predecessor_submission_version, - checker_remediation_manifest_id=None, guide_version=refs.guide_version, source_snapshot_id=str(refs.source_snapshot_id), source_snapshot_sha256=refs.source_snapshot_hash, effective_policy_id=str(refs.effective_policy_id), - locked_artifact_policy_sha256=refs.effective_policy_hash, + effective_policy_sha256=refs.effective_policy_hash, pre_submit_policy_id=str(refs.pre_submit_policy_id), - locked_checker_policy_sha256=refs.pre_submit_policy_bundle_hash, - locked_policy_context_hash=admission.locked_policy_context_hash, - semantic_manifest_id=admission.semantic_manifest_id, - semantic_manifest_sha256=admission.semantic_manifest_sha256, - semantic_manifest_body=manifest.as_dict(), - archive_sha256=admission.archive_sha256, - archive_byte_count=admission.archive_byte_count, + pre_submit_policy_sha256=refs.pre_submit_policy_bundle_hash, guide_id=str(new_record_id()), - terminal_status="passed", - eligible=True, ) content = SimpleNamespace( id=content_id, sha256=admission.archive_sha256, byte_count=admission.archive_byte_count, ) - return admission, evidence, content + return admission, content class _Allow: @@ -174,9 +166,9 @@ async def test_explicit_denial_precedes_admission_disclosure() -> None: @pytest.mark.asyncio async def test_ready_admission_creates_exact_binding_and_consumes_once() -> None: request = _request() - admission, evidence, content = _lineage(request) + admission, content = _lineage(request) now = datetime.now(UTC) - session = _session(admission, evidence, content, None, now) + session = _session(admission, content, None, now) authority = _Allow() result = await SubmissionAdmissionConsumptionService(session, authority).consume(request) @@ -195,7 +187,8 @@ async def test_ready_admission_creates_exact_binding_and_consumes_once() -> None assert admission.consumed_at == now facts = authority.consume.await_args.args[0] assert facts.admission_id == request.admission_id - assert facts.evidence_set_id.hex == evidence.id.replace("-", "") + assert facts.evidence_set_id is None + assert facts.selected_pre_submit_run_id == UUID(admission.selected_pre_submit_run_id) assert facts.actor_profile_id == request.task_context.contributor_id assert facts.identity_link_id.hex == admission.identity_link_id.replace("-", "") assert facts.project_id == request.task_context.locked_project_context.project_id @@ -205,17 +198,17 @@ async def test_ready_admission_creates_exact_binding_and_consumes_once() -> None assert facts.predecessor_submission_version is None assert facts.submission_id == request.submission_id assert facts.submission_version == request.submission_version - assert facts.guide_id.hex == evidence.guide_id.replace("-", "") - assert facts.guide_version == evidence.guide_version - assert facts.source_snapshot_id.hex == evidence.source_snapshot_id.replace("-", "") - assert facts.source_snapshot_sha256 == evidence.source_snapshot_sha256 - assert facts.effective_policy_id.hex == evidence.effective_policy_id.replace("-", "") - assert facts.effective_policy_sha256 == evidence.locked_artifact_policy_sha256 - assert facts.pre_submit_policy_id.hex == evidence.pre_submit_policy_id.replace("-", "") - assert facts.pre_submit_policy_sha256 == evidence.locked_checker_policy_sha256 - assert facts.locked_policy_context_hash == evidence.locked_policy_context_hash - assert facts.semantic_manifest_id.hex == evidence.semantic_manifest_id.replace("-", "") - assert facts.semantic_manifest_sha256 == evidence.semantic_manifest_sha256 + assert facts.guide_id.hex == admission.guide_id.replace("-", "") + assert facts.guide_version == admission.guide_version + assert facts.source_snapshot_id.hex == admission.source_snapshot_id.replace("-", "") + assert facts.source_snapshot_sha256 == admission.source_snapshot_sha256 + assert facts.effective_policy_id.hex == admission.effective_policy_id.replace("-", "") + assert facts.effective_policy_sha256 == admission.effective_policy_sha256 + assert facts.pre_submit_policy_id.hex == admission.pre_submit_policy_id.replace("-", "") + assert facts.pre_submit_policy_sha256 == admission.pre_submit_policy_sha256 + assert facts.locked_policy_context_hash == admission.locked_policy_context_hash + assert facts.semantic_manifest_id.hex == admission.semantic_manifest_id.replace("-", "") + assert facts.semantic_manifest_sha256 == admission.semantic_manifest_sha256 assert facts.content_id == result.content_id assert facts.sha256 == admission.archive_sha256 assert facts.byte_count == admission.archive_byte_count @@ -231,9 +224,9 @@ async def test_ready_admission_creates_exact_binding_and_consumes_once() -> None @pytest.mark.asyncio async def test_final_authority_denial_leaves_ready_admission_unchanged() -> None: request = _request() - admission, evidence, content = _lineage(request) + admission, content = _lineage(request) authority = _DenyFinal() - session = _session(admission, evidence, content, None) + session = _session(admission, content, None) with pytest.raises( SubmissionAdmissionConsumptionError, @@ -251,10 +244,10 @@ async def test_final_authority_denial_leaves_ready_admission_unchanged() -> None @pytest.mark.asyncio async def test_proven_task_lineage_change_marks_ready_admission_stale() -> None: request = _request() - admission, evidence, content = _lineage(request) - evidence.source_snapshot_sha256 = _sha("9") + admission, content = _lineage(request) + admission.source_snapshot_sha256 = _sha("9") now = datetime.now(UTC) - session = _session(admission, evidence, content, now) + session = _session(admission, content, now) authority = _Allow() result = await SubmissionAdmissionConsumptionService(session, authority).consume(request) @@ -271,9 +264,9 @@ async def test_proven_task_lineage_change_marks_ready_admission_stale() -> None: @pytest.mark.asyncio async def test_broken_art_lineage_denies_without_staling_admission() -> None: request = _request() - admission, evidence, content = _lineage(request) + admission, content = _lineage(request) content.sha256 = _sha("9") - session = _session(admission, evidence, content) + session = _session(admission, content) authority = _Allow() with pytest.raises( @@ -292,13 +285,13 @@ async def test_broken_art_lineage_denies_without_staling_admission() -> None: @pytest.mark.asyncio async def test_matching_consumed_admission_replays_exact_binding() -> None: request = _request() - admission, evidence, content = _lineage(request) + admission, content = _lineage(request) admission.status = "consumed" decision_id = new_record_id() admission.consumed_by_submission_id = str(request.submission_id) admission.consumed_by_submission_version = request.submission_version binding = SimpleNamespace(id=str(new_record_id()), content_id=admission.artifact_content_id) - session = _session(admission, evidence, content, binding) + session = _session(admission, content, binding) session.get.return_value = SimpleNamespace(binding_id=binding.id, decision_id=str(decision_id)) authority = _Allow() @@ -317,7 +310,7 @@ async def test_matching_consumed_admission_replays_exact_binding() -> None: @pytest.mark.asyncio async def test_consumed_admission_rejects_different_submission() -> None: request = _request() - admission, _, _ = _lineage(request) + admission, _ = _lineage(request) admission.status = "consumed" admission.consumed_by_submission_id = str(new_record_id()) admission.consumed_by_submission_version = request.submission_version @@ -343,11 +336,11 @@ async def test_consumed_admission_rejects_wrong_submission_version() -> None: submission_version=2, task_context=original.task_context, ) - admission, evidence, content = _lineage(original) + admission, content = _lineage(original) admission.status = "consumed" admission.consumed_by_submission_id = str(original.submission_id) admission.consumed_by_submission_version = original.submission_version - session = _session(admission, evidence, content, None) + session = _session(admission, content, None) with pytest.raises( SubmissionAdmissionConsumptionError, @@ -359,7 +352,7 @@ async def test_consumed_admission_rejects_wrong_submission_version() -> None: @pytest.mark.asyncio async def test_stale_admission_is_terminal_without_art_mutation() -> None: request = _request() - admission, _, _ = _lineage(request) + admission, _ = _lineage(request) admission.status = "stale" admission.stale_at = datetime.now(UTC) admission.stale_reason = "locked_submission_context_changed" @@ -394,14 +387,14 @@ async def test_consumption_requires_caller_owned_root_transaction() -> None: @pytest.mark.parametrize("corruption", ["absent", "file_hash", "foreign_body"]) async def test_consumption_rejects_invalid_manifest_before_binding(corruption): request = _request() - admission, evidence, content = _lineage(request) + admission, content = _lineage(request) if corruption == "absent": - evidence.semantic_manifest_body = None + admission.semantic_manifest_body = None elif corruption == "file_hash": - evidence.semantic_manifest_body["entries"][0]["sha256"] = "sha256:" + "f" * 64 + admission.semantic_manifest_body["entries"][0]["sha256"] = "sha256:" + "f" * 64 else: - evidence.semantic_manifest_body = _manifest(_archive([("foreign.txt", b"other")])).as_dict() - session = _session(admission, evidence, content) + admission.semantic_manifest_body = _manifest(_archive([("foreign.txt", b"other")])).as_dict() + session = _session(admission, content) authority = _Allow() with pytest.raises(SubmissionAdmissionConsumptionError, match="submission_bundle_admission_unavailable"): await SubmissionAdmissionConsumptionService(session, authority).consume(request) @@ -415,7 +408,7 @@ async def test_consumption_rejects_invalid_manifest_before_binding(corruption): @pytest.mark.parametrize("field", ["summary", "contributor_attestation"]) async def test_changed_packet_denies_before_binding_or_consumed_replay(status, field): original = _request() - admission, evidence, content = _lineage(original) + admission, content = _lineage(original) admission.status = status if status == "consumed": admission.consumed_by_submission_id = str(original.submission_id) @@ -425,7 +418,7 @@ async def test_changed_packet_denies_before_binding_or_consumed_replay(status, f request = replace(original, packet_sha256=changed.sha256) binding = SimpleNamespace(id=str(new_record_id()), content_id=admission.artifact_content_id) continuation = (binding,) if status == "consumed" else (None, datetime.now(UTC)) - session = _session(admission, evidence, content, *continuation) + session = _session(admission, content, *continuation) session.get.return_value = SimpleNamespace(binding_id=UUID(binding.id), decision_id=new_record_id()) authority = _Allow() expected = "submission_bundle_admission_unavailable" if status == "ready" else "submission_bundle_admission_context_changed" diff --git a/backend/tests/test_artifact_bindings_db.py b/backend/tests/test_artifact_bindings_db.py index b20650790..4663f625d 100644 --- a/backend/tests/test_artifact_bindings_db.py +++ b/backend/tests/test_artifact_bindings_db.py @@ -14,7 +14,6 @@ from app.modules.artifacts.models import ( ArtifactBinding, ArtifactContent, - PreSubmitEvidenceSet, SubmissionBundleAdmission, SubmissionBindingReceipt, ) from app.modules.artifacts.submission_bindings import ( @@ -33,7 +32,6 @@ _TABLES = ( ArtifactContent.__table__, - PreSubmitEvidenceSet.__table__, SubmissionBundleAdmission.__table__, ArtifactBinding.__table__, Submission.__table__, @@ -113,7 +111,7 @@ async def _set_schema(session, schema: str) -> None: async def _seed(session, schema: str, request) -> None: - admission, evidence, content = _lineage(request) + admission, content = _lineage(request) await _set_schema(session, schema) await session.execute( ArtifactContent.__table__.insert().values( @@ -122,9 +120,6 @@ async def _seed(session, schema: str, request) -> None: byte_count=content.byte_count, ) ) - await session.execute( - PreSubmitEvidenceSet.__table__.insert().values(**vars(evidence)) - ) await session.execute( SubmissionBundleAdmission.__table__.insert().values( **vars(admission), durable_intent_id=str(new_record_id()), put_attempt_id=str(new_record_id()), @@ -248,7 +243,12 @@ async def consume_once(): assert exc.code == "submission_bundle_admission_already_consumed" return None - outcomes = await asyncio.gather(consume_once(), consume_once()) + contenders = [asyncio.create_task(consume_once()) for _ in range(2)] + try: + outcomes = await asyncio.gather(*contenders) + finally: + # Join both transactions before schema teardown even if one fails. + await asyncio.gather(*contenders, return_exceptions=True) assert sum(result is not None for result in outcomes) == 1 original = next(result for result in outcomes if result is not None) async with factory.begin() as session: @@ -289,10 +289,14 @@ async def consume_competing(value): SubmissionBundleAdmission, str(value.admission_id) )).status - outcomes = await asyncio.gather( - consume_competing(first_competing), - consume_competing(competing), - ) + contenders = [ + asyncio.create_task(consume_competing(first_competing)), + asyncio.create_task(consume_competing(competing)), + ] + try: + outcomes = await asyncio.gather(*contenders) + finally: + await asyncio.gather(*contenders, return_exceptions=True) assert sorted(outcomes) == [ "consumed", "stale", diff --git a/backend/tests/test_artifact_preparation.py b/backend/tests/test_artifact_preparation.py index 246ebba3f..68eebf8e9 100644 --- a/backend/tests/test_artifact_preparation.py +++ b/backend/tests/test_artifact_preparation.py @@ -1296,50 +1296,6 @@ async def test_malformed_ledger_and_layout_fail_closed(tmp_path: Path) -> None: ArtifactScratchManager(root=unsafe_root, limits=preparation_limits()) -@pytest.mark.asyncio -async def test_version_two_scratch_ledger_is_normalized_on_reopen(tmp_path: Path) -> None: - """Preserve restart compatibility for the genuine version-two ledger.""" - root = tmp_path / "scratch" - initial = ArtifactScratchManager(root=root, limits=preparation_limits()) - owner_process_identity = initial._owner_process_identity - initial.close() - ledger_path = root / ".ledger.json" - ledger = json.loads(ledger_path.read_text(encoding="utf-8")) - ledger["version"] = 2 - workspace_name = f"extract_{'a' * 32}" - (root / "workspaces" / workspace_name).mkdir(mode=0o700) - ledger["workspaces"] = [ - { - "workspace_name": workspace_name, - "created_at_unix_ns": 1, - "expires_at_unix_ns": 10**20, - "owner_pid": os.getpid(), - "owner_process_identity": owner_process_identity, - } - ] - ledger_path.write_text(json.dumps(ledger), encoding="utf-8") - - reopened = ArtifactScratchManager(root=root, limits=preparation_limits()) - reservation, descriptor = await reopened.allocate() - os.close(descriptor) - await reopened.release(reservation) - - normalized = json.loads(ledger_path.read_text(encoding="utf-8")) - assert normalized["version"] == 3 - assert normalized["workspaces"] == [ - { - "workspace_name": workspace_name, - "reserved_bytes": 0, - "maximum_entries": preparation_limits().maximum_workspace_entries, - "created_at_unix_ns": 1, - "expires_at_unix_ns": 10**20, - "owner_pid": os.getpid(), - "owner_process_identity": owner_process_identity, - } - ] - reopened.close() - - @pytest.mark.asyncio async def test_truncated_version_three_scratch_ledger_fails_closed(tmp_path: Path) -> None: """Reject a current-version ledger missing workspace custody state.""" @@ -1355,21 +1311,6 @@ async def test_truncated_version_three_scratch_ledger_fails_closed(tmp_path: Pat ArtifactScratchManager(root=root, limits=preparation_limits()) -def test_legacy_safe_empty_root_marker_upgrades_once(tmp_path: Path) -> None: - """Upgrade only the exact prior marker while the scratch root is empty.""" - limits = preparation_limits() - root = tmp_path / "scratch" - initial = ArtifactScratchManager(root=root, limits=limits) - initial.close() - marker = root / ".workstream-artifact-scratch-v1" - marker.write_bytes(ArtifactScratchManager._legacy_marker_content(limits)) - - reopened = ArtifactScratchManager(root=root, limits=limits) - reopened.close() - - assert marker.read_bytes() == ArtifactScratchManager._marker_content(limits) - - @pytest.mark.asyncio async def test_ledger_read_failures_are_explicit_and_bounded(tmp_path: Path) -> None: """Reject missing, oversized, and malformed coordination documents.""" diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index c774cf735..71f7d30fe 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -1951,6 +1951,27 @@ def test_arch03a_partition_transition_accepts_only_exact_policy_lineage_relocati ownership._validate_additive_partition_transition(_partition(after), _partition(before)) +def test_pilot04_partition_allows_only_the_selected_flow_and_retired_catalogues() -> None: + """The selected checker cutover admits its exact owners and retirements.""" + retained = "backend/app/core/config.py" + retired = ownership.PILOT_04_RETIRED_CATALOGUE_TARGETS + trusted = _partition(sorted({retained, *retired})) + selected = ownership.PILOT_04_SELECTED_FLOW_TARGETS + assert retired.isdisjoint(selected) + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *selected})), trusted + ) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition(sorted(selected)), trusted + ) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition(sorted({*selected, "backend/app/modules/checkers/unreviewed.py"})), + trusted, + ) + + def test_command_replay_partition_addition_does_not_authorize_neighbors() -> None: """Admit the exact replay owner without opening adjacent unreviewed paths.""" retained = "backend/app/core/config.py" diff --git a/backend/tests/test_blocked_pre_submit_feedback_postgresql.py b/backend/tests/test_blocked_pre_submit_feedback_postgresql.py deleted file mode 100644 index b4af0e3d8..000000000 --- a/backend/tests/test_blocked_pre_submit_feedback_postgresql.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Real blocked preparation feedback, replay, and no-product-effect proof.""" - -from __future__ import annotations - -from contextlib import asynccontextmanager -from dataclasses import replace -from pathlib import Path - -import pytest -from sqlalchemy import func, select, text - -from app.adapters.artifacts import CheckerPhaseService -from app.adapters.checkers import submission_evaluation_content -from app.adapters.tasks import task_submission_context_port -from app.core.hashing import canonical_json_hash -from app.core.identifiers import new_record_id -from app.modules.artifacts.api import SubmissionBundlePreparationCheckFailed -from app.modules.artifacts.authorization import ( - PreparedPreSubmitMaterializationAuthorization, -) -from app.modules.artifacts.models import ( - PreSubmitEvidenceResult, - PreSubmitEvidenceSet, - PreSubmitExecutionAttempt, -) -from app.modules.artifacts.submission_admission import ( - PreparedSubmissionBundlePreparationCommand, - SubmissionBundlePreparationRuntime, -) -from app.modules.checkers.api.execution import REQUEST_EVENT -from app.modules.outbox.api import OutboxAppendDisposition, OutboxAppendInput -from app.modules.outbox.service import OutboxService -from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository -from tests.authorization.test_pre_submit_attempt_authority import _seed_materializer -from tests.checkers.execution.support import forbidden_post_submission -from tests.test_default_pre_submit_execution import _archive, _bytes -from tests.test_pre_submit_attempt_recovery import _harness - - -async def _authorized_harness(tmp_path: Path, database_url: str): - harness = await _harness(tmp_path, database_url) - await harness.request.prepared_artifact.close() - await _seed_materializer(harness.factory) - return harness - - -async def _product_state(harness) -> dict[str, object]: - async with harness.factory() as session: - values = ( - await session.execute( - text( - "select " - "(select status from workstream_tasks where id=:task) as task_status," - "(select count(*) from submissions) as submissions," - "(select max(version) from submissions) as max_version," - "(select count(*) from submission_bundle_admissions) as admissions," - "(select count(*) from submission_bundle_admissions " - " where status='ready') as ready_admissions," - "(select count(*) from checker_runs) as checker_runs," - "(select count(*) from audit_events " - " where event_domain<>'authority') as lifecycle_audit_events," - "(select count(*) from outbox_events) as outbox_events" - ), - {"task": str(harness.request.task_id)}, - ) - ).mappings().one() - await session.rollback() - return dict(values) - - -def _assert_no_product_effects( - actual: dict[str, object], expected: dict[str, object] -) -> None: - for field, expected_value in expected.items(): - assert actual[field] == expected_value, f"{field} changed" - assert set(actual) == set(expected) - - -async def _evidence_state(harness): - async with harness.factory() as session: - attempt = await session.scalar( - select(PreSubmitExecutionAttempt).where( - PreSubmitExecutionAttempt.task_id == str(harness.request.task_id) - ) - ) - assert attempt is not None and attempt.status == "completed" - evidence = await session.get(PreSubmitEvidenceSet, attempt.evidence_set_id) - assert evidence is not None and evidence.eligible is False - result_ids = tuple( - await session.scalars( - select(PreSubmitEvidenceResult.id) - .where(PreSubmitEvidenceResult.evidence_set_id == evidence.id) - .order_by(PreSubmitEvidenceResult.result_order) - ) - ) - counts = ( - await session.scalar(select(func.count()).select_from(PreSubmitEvidenceSet)), - await session.scalar(select(func.count()).select_from(PreSubmitEvidenceResult)), - await session.scalar(text("select count(*) from checker_runs")), - ) - identity = evidence.id, result_ids, counts - await session.rollback() - return identity - - -async def _invoke_blocked(harness, calls: list[int]): - async with harness.factory() as session: - contributor = harness.contributor_authority(session) - materializer = PreparedPreSubmitMaterializationAuthorization( - session, - request_id=harness.preparation_request.request_id, - correlation_id=harness.preparation_request.correlation_id, - ) - workflow = harness.workflow( - session, - calls, - preparation_authorization=contributor, - ) - workflow._materialization._authorization = materializer - - @asynccontextmanager - async def runtime(): - try: - yield SubmissionBundlePreparationRuntime( - preparation=harness.preparation, - inspector=harness.inspector, - catalogue=harness.catalogue, - materialization=workflow._materialization, - evidence=workflow, - checker_service=CheckerPhaseService( - pre_submission=workflow, - post_submission=forbidden_post_submission(), - ), - durable_put=object(), - evaluation_content=submission_evaluation_content, - ) - finally: - materializer.close() - - command = PreparedSubmissionBundlePreparationCommand( - session=session, - authority=contributor, - task_contexts=task_submission_context_port(session), - project_contexts=ProjectLockedPolicyRepository(session), - runtime_factory=runtime, - ) - request = replace( - harness.preparation_request, - contributor_attestation="", - byte_source=_bytes(_archive(evidence_path=harness.evidence_path)), - ) - with pytest.raises(SubmissionBundlePreparationCheckFailed) as failure: - await command.prepare(request) - return failure.value.facts - - -def _submission_event(harness) -> OutboxAppendInput: - submission_id = new_record_id() - request_id = new_record_id() - digest = canonical_json_hash({"submission_id": str(submission_id)}) - return OutboxAppendInput( - event_type=REQUEST_EVENT, - event_version=1, - aggregate_type="submission", - aggregate_id=submission_id, - project_id=harness.request.effective_plan.lineage.project_id, - correlation_id=str(request_id), - idempotency_key=f"submission-evaluation:{submission_id}", - payload={ - "project_id": str(harness.request.effective_plan.lineage.project_id), - "task_id": str(harness.request.task_id), - "submission_id": str(submission_id), - "submission_version": 1, - "request_id": str(request_id), - "request_digest": digest, - "evaluation_generation": 1, - "attempt_id": str(new_record_id()), - "result_id": str(new_record_id()), - "creation_decision_id": str(new_record_id()), - "binding_decision_id": str(new_record_id()), - }, - ) - - -@pytest.mark.asyncio -async def test_authorized_blocked_command_replays_exact_feedback_without_product_effects( - tmp_path: Path, - isolated_database_env: str, -) -> None: - harness = await _authorized_harness(tmp_path, isolated_database_env) - calls: list[int] = [] - try: - before = await _product_state(harness) - assert before["task_status"] == "in_progress" - assert before["submissions"] == 0 - assert before["max_version"] is None - - first_feedback = await _invoke_blocked(harness, calls) - assert first_feedback.eligible is False - assert any(entry.failure_code is not None for entry in first_feedback.entries) - assert calls == [1] - - first_identity = await _evidence_state(harness) - _assert_no_product_effects(await _product_state(harness), before) - - replay_feedback = await _invoke_blocked(harness, calls) - assert replay_feedback == first_feedback - assert calls == [1] - assert await _evidence_state(harness) == first_identity - _assert_no_product_effects(await _product_state(harness), before) - finally: - await harness.close() - - -@pytest.mark.asyncio -async def test_no_product_effect_snapshot_detects_submission_aggregate_outbox( - tmp_path: Path, - isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - try: - before = await _product_state(harness) - async with harness.factory() as session, session.begin(): - result = await OutboxService(session).append(_submission_event(harness)) - assert result.disposition is OutboxAppendDisposition.CREATED - - after = await _product_state(harness) - assert after["outbox_events"] == before["outbox_events"] + 1 - with pytest.raises(AssertionError, match="outbox_events changed"): - _assert_no_product_effects(after, before) - finally: - await harness.close() diff --git a/backend/tests/test_checker_catalogue.py b/backend/tests/test_checker_catalogue.py deleted file mode 100644 index d6eb249bc..000000000 --- a/backend/tests/test_checker_catalogue.py +++ /dev/null @@ -1,686 +0,0 @@ -"""Proof for the single pre-submission catalogue and effective plan compiler.""" - -from __future__ import annotations - -from app.core.config import get_settings - -from app.adapters.tasks import task_service - -from dataclasses import replace -import json -from app.core.identifiers import new_record_id - -import pytest - -from app.core.hashing import canonical_json_hash -from app.core.config import Settings -from app.main import create_app -from app.modules.checkers.catalogue import ( - PRE_SUBMISSION_CATALOGUE_ID, - PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION, - PreSubmissionCatalogueError, - PreSubmissionCheckerCatalogue, - PreSubmissionDisabledBehavior, - PreSubmissionCheckerPhase, - PreSubmissionCheckerState, - build_pre_submission_checker_catalogue, - parse_disabled_pre_submission_checker_ids, -) -from app.modules.checkers.compiler import ( - compile_effective_project_submission_artifact_policy, -) -from app.modules.checkers.effective_plan import ( - EffectivePreSubmissionPlanError, - EffectivePreSubmissionPlanLineage, - PreSubmissionInfrastructureUnavailableError, - compile_effective_pre_submission_execution_plan, -) - - -def _effective_policy() -> dict[str, object]: - default_policy = { - "required_packet_fields": ["summary", "worker_attestation"], - "forbidden_artifacts": [{"pattern": ".env"}, {"pattern": ".git/**"}], - "attestation_terms": ["rights_confirmed"], - } - return { - "workstream_default_policy": default_policy, - "project_policy": {}, - "required_packet_fields": default_policy["required_packet_fields"], - "required_artifacts": [ - {"key": "task.toml", "path": "task.toml", "required": True} - ], - "required_evidence": [{"key": "results", "required": True}], - "forbidden_artifacts": default_policy["forbidden_artifacts"], - "attestation_terms": default_policy["attestation_terms"], - "manifest_required": True, - "artifact_hash_required": True, - "artifact_hash_algorithm": "sha256", - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": 1_000_000, - "maximum_package_size_bytes": 5_000_000, - "packaging": {"package_required": True, "allowed_package_formats": ["zip"]}, - } - - -def _compiled_and_lineage() -> tuple[dict[str, object], EffectivePreSubmissionPlanLineage]: - effective_policy_hash = canonical_json_hash(_effective_policy()) - compiled = compile_effective_project_submission_artifact_policy( - _effective_policy(), effective_policy_hash - ) - lineage = EffectivePreSubmissionPlanLineage( - project_id=new_record_id(), - guide_id=new_record_id(), - guide_version="3", - source_snapshot_id=new_record_id(), - source_snapshot_hash="sha256:" + "2" * 64, - effective_policy_id=new_record_id(), - effective_policy_hash=effective_policy_hash, - pre_submit_policy_id=new_record_id(), - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, - ) - return compiled.compiled_bundle, lineage - - -def test_catalogue_is_single_canonical_closed_namespace() -> None: - catalogue = build_pre_submission_checker_catalogue() - ids = [entry.stable_id for entry in catalogue.entries] - - assert catalogue.catalogue_id == PRE_SUBMISSION_CATALOGUE_ID - assert catalogue.schema_version == PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION - assert len(ids) == len(set(ids)) == 28 - assert ids == [ - entry.stable_id - for entry in sorted( - catalogue.entries, - key=lambda entry: ( - list(PreSubmissionCheckerPhase).index(entry.phase), - entry.order, - entry.stable_id, - ), - ) - ] - assert catalogue.manifest_sha256 == canonical_json_hash(dict(catalogue.manifest)) - - expected = { - "enforce_storage_scheme": ("policy.storage_scheme.enforce", "check_evidence_integrity"), - "require_manifest_field": ("policy.manifest_field.require", "check_evidence_integrity"), - "limit_file_size": ("policy.file_size.limit", "check_evidence_integrity"), - "limit_package_size": ("policy.package_size.limit", "check_evidence_integrity"), - "limit_archive_entries": ("policy.archive_entries.limit", "check_evidence_integrity"), - "limit_archive_size": ("policy.archive_size.limit", "check_evidence_integrity"), - } - for primitive, (stable_id, public_name) in expected.items(): - definition = catalogue.primitive_definition(primitive) - assert (definition.stable_id, definition.public_name) == (stable_id, public_name) - - -@pytest.mark.parametrize("limit", [True, False, 0, -1, "100", 1.5]) -@pytest.mark.parametrize("field", ["maximum_archive_entries", "maximum_archive_size_bytes"]) -def test_archive_limit_rejects_invalid_policy_and_compiler_input(limit, field) -> None: - from pydantic import ValidationError - from app.interfaces.project_agents import SubmissionArtifactPolicyProposal - from app.modules.projects.schemas import SubmissionArtifactPolicyInput - from app.modules.checkers.compiler import PreSubmitCheckerCompilerError - - with pytest.raises(ValidationError): - SubmissionArtifactPolicyInput(**{field: limit}) - with pytest.raises(ValidationError): - SubmissionArtifactPolicyProposal( - maximum_file_size_bytes=100, maximum_package_size_bytes=1000, - **{field: limit}, - ) - policy = {**_effective_policy(), field: limit} - with pytest.raises(PreSubmitCheckerCompilerError): - compile_effective_project_submission_artifact_policy(policy, canonical_json_hash(policy)) - - -@pytest.mark.parametrize("alteration", ["missing", "weakened", "boolean", "warning"]) -@pytest.mark.parametrize("field, primitive", [ - ("maximum_archive_entries", "limit_archive_entries"), - ("maximum_archive_size_bytes", "limit_archive_size"), -]) -def test_archive_limit_compiler_rejects_rule_substitution(alteration, field, primitive) -> None: - from copy import deepcopy - from app.modules.checkers.compiler import ( - PreSubmitCheckerCompilerError, validate_compiled_pre_submit_checker_bundle, - ) - - policy = {**_effective_policy(), field: 1} - digest = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, digest) - bundle = deepcopy(compiled.compiled_bundle) - rule = next(item for item in bundle["rules"] if item["primitive"] == primitive) - if alteration == "missing": - bundle["rules"].remove(rule) - elif alteration == "warning": - rule["severity"] = "warning" - else: - rule["config"][field] = True if alteration == "boolean" else 2 - with pytest.raises(PreSubmitCheckerCompilerError): - validate_compiled_pre_submit_checker_bundle(policy, digest, bundle) - - -@pytest.mark.parametrize("value", [True, 0, -1, "2", 2.5]) -@pytest.mark.parametrize("field", ["maximum_archive_entries", "maximum_archive_size_bytes"]) -def test_locked_archive_limit_rejects_malformed_values(value, field): - from app.modules.tasks.service import TaskLockedContextInvalid - - policy = {**_effective_policy(), field: value} - with pytest.raises(TaskLockedContextInvalid): - task_service(None, settings=get_settings())._optional_policy_non_negative_int(policy, field, minimum=1) - - -@pytest.mark.parametrize("field, primitive", [ - ("maximum_archive_entries", "limit_archive_entries"), - ("maximum_archive_size_bytes", "limit_archive_size"), -]) -def test_archive_limit_changes_locked_hashes_and_preserves_null_semantics(field, primitive): - - hashes = set() - for limit in (None, 1, 100): - policy = {**_effective_policy(), field: limit} - digest = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, digest) - rules = [item for item in compiled.compiled_bundle["rules"] - if item["primitive"] == primitive] - assert len(rules) == (0 if limit is None else 1) - assert task_service(None, settings=get_settings())._optional_policy_non_negative_int( - policy, field, minimum=1, - ) == limit - hashes.add((digest, compiled.compiled_bundle_hash)) - assert len(hashes) == 3 - - -@pytest.mark.parametrize("count, size, archive_delta, file_limit, failure, task_path", [ - (3, 4101, None, None, None, "task.toml"), - (2, 4101, None, None, "policy.archive_entries.limit", "task.toml"), - (3, 4100, None, None, "policy.package_size.limit", "task.toml"), - (None, 4101, None, None, None, "task.toml"), - (4, 4101, None, None, "policy.file.require", "wrapper/task.toml"), - (3, 4101, 0, None, None, "task.toml"), - (3, 4101, -1, None, "policy.archive_size.limit", "task.toml"), - (3, 4101, None, 4096, None, "task.toml"), - (3, 4101, None, 4095, "policy.file_size.limit", "task.toml"), -]) -@pytest.mark.parametrize("explicit_directory", [True, False]) -def test_real_zip_enforces_locked_archive_limits( - tmp_path, count, size, archive_delta, file_limit, failure, task_path, explicit_directory, -) -> None: - from hashlib import sha256 - from io import BytesIO - from zipfile import ZipFile, ZIP_DEFLATED, ZIP_STORED - from app.modules.artifacts.sources import ArtifactCommitment - from app.modules.artifacts.submission_archive import SubmissionArchiveInspector, SubmissionArchiveLimits - from app.modules.artifacts.submission_manifest import build_submission_manifest, evaluate_submission_change - from app.modules.checkers.pre_submit_execution import ( - DefaultPreSubmissionExecutionInput, EffectivePreSubmissionProcessor, - SubmissionPacketView, PreSubmissionResultStatus, - ) - - source = BytesIO() - with ZipFile(source, "w", compression=ZIP_DEFLATED) as archive: - if explicit_directory: - archive.writestr("evidence/", b"", compress_type=ZIP_STORED) - archive.writestr(task_path, bytes(range(256)) * 16) - archive.writestr("evidence/results", b"proof") - data = source.getvalue() - inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) - inspection = inspector.inspect(BytesIO(data)) - manifest = build_submission_manifest(inspection) - assert manifest.entry_count == (4 if task_path.startswith("wrapper/") else 3) - assert inspection.file_count == 2 - assert len(data) < 4100 < manifest.total_expanded_bytes == 4101 - commitment = ArtifactCommitment("sha256:" + sha256(data).hexdigest(), len(data), "application/zip") - policy = {**_effective_policy(), "maximum_archive_entries": count, "maximum_package_size_bytes": size} - policy["maximum_archive_size_bytes"] = None if archive_delta is None else len(data) + archive_delta - if file_limit is not None: - policy["maximum_file_size_bytes"] = file_limit - digest = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, digest) - _, lineage = _compiled_and_lineage() - lineage = replace(lineage, effective_policy_hash=digest, - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash) - catalogue = build_pre_submission_checker_catalogue() - plan = compile_effective_pre_submission_execution_plan( - lineage=lineage, effective_policy=policy, compiled_bundle=compiled.compiled_bundle, catalogue=catalogue, - ) - execution = DefaultPreSubmissionExecutionInput( - plan=plan, commitment=commitment, inspection=inspection, manifest=manifest, - change_gate=evaluate_submission_change(commitment=commitment, manifest=manifest, - predecessor=None, predecessor_exists=False), - packet=SubmissionPacketView(summary="Completed work", contributor_attestation=( - "No confidential client data, credentials, or copied source material; rights_confirmed." - )), - prepared_generation_id=new_record_id(), storage_scheme="s3", - ) - workspace = tmp_path / "private" - workspace.mkdir(mode=0o700) - result = EffectivePreSubmissionProcessor( - archive_inspector=inspector, catalogue=catalogue, execution_input=execution, - ).process_blocking(BytesIO(data), workspace) - failed = [item.definition.definition_id for item in result.entries - if item.status is PreSubmissionResultStatus.FAILED] - assert failed == ([] if failure is None else [failure]) - assert result.eligible is (failure is None) - - -def test_compressed_limit_declares_commitment_in_catalogue_projection_and_plan(): - from app.modules.checkers.catalogue import project_guide_pre_submission_capabilities - - catalogue = build_pre_submission_checker_catalogue() - expected = ("LockedProjectCheckerRule", "ArtifactCommitment") - definition = catalogue.definition("policy.archive_size.limit") - assert definition.typed_inputs == expected - projection = project_guide_pre_submission_capabilities(catalogue) - projected = next(item for item in projection.definitions if item.stable_id == definition.stable_id) - assert projected.typed_inputs == expected - policy = {**_effective_policy(), "maximum_archive_size_bytes": 1000} - digest = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, digest) - _, lineage = _compiled_and_lineage() - lineage = replace(lineage, effective_policy_hash=digest, - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash) - plan = compile_effective_pre_submission_execution_plan( - lineage=lineage, effective_policy=policy, - compiled_bundle=compiled.compiled_bundle, catalogue=catalogue, - ) - entry = next(item for item in plan.entries if item.definition_id == definition.stable_id) - assert entry.typed_inputs == expected - manifest_entry = next(item for item in catalogue.manifest["entries"] - if item["stable_id"] == definition.stable_id) - assert manifest_entry["typed_inputs"] == list(expected) - assert plan.plan_sha256 == canonical_json_hash(plan.as_dict()) - - -def test_catalogue_exact_v01_contract_is_locked() -> None: - catalogue = build_pre_submission_checker_catalogue() - actual = { - "|".join( - ( - entry.stable_id, - entry.version, - entry.public_name, - entry.classification.value, - entry.phase.value, - str(entry.order), - entry.dispatch_kind.value, - entry.dispatch_capability, - entry.primitive or "-", - entry.disabled_behavior.value, - ",".join(f"{name}={value}" for name, value in entry.resource_budget), - ) - ) - for entry in catalogue.entries - } - expected = { - "artifact.outer_zip.valid|v1|artifact.outer_zip.valid|mandatory_security|custody|10|platform_capability|submission_archive.outer_zip_valid|-|infrastructure_unavailable|maximum_results=1", - "artifact.archive.paths_safe|v1|artifact.archive.paths_safe|mandatory_security|custody|20|platform_capability|submission_archive.paths_safe|-|infrastructure_unavailable|maximum_results=1", - "artifact.archive.entries_safe|v1|Reject encrypted ZIP entries, symbolic links and special files|mandatory_security|custody|30|platform_capability|submission_archive.entries_safe|-|infrastructure_unavailable|maximum_results=1", - "artifact.archive.resources_bounded|v1|artifact.archive.resources_bounded|mandatory_security|custody|40|platform_capability|submission_archive.resources_bounded|-|infrastructure_unavailable|maximum_results=1", - "artifact.archive.integrity_verified|v1|artifact.archive.integrity_verified|mandatory_integrity|custody|50|platform_capability|submission_archive.integrity_verified|-|infrastructure_unavailable|maximum_results=1", - "artifact.archive.identity_computed|v1|artifact.archive.identity_computed|mandatory_integrity|identity|10|platform_capability|artifact_commitment.identity|-|infrastructure_unavailable|maximum_results=1", - "artifact.manifest.semantic_identity_computed|v1|artifact.manifest.semantic_identity_computed|mandatory_integrity|identity|20|platform_capability|submission_manifest.semantic_identity|-|infrastructure_unavailable|maximum_results=1", - "artifact.manifest.executable_normalized|v1|artifact.manifest.executable_normalized|mandatory_integrity|identity|30|platform_capability|submission_manifest.executable_normalized|-|infrastructure_unavailable|maximum_results=1", - "artifact.revision.content_changed|v1|artifact.revision.content_changed|mandatory_integrity|identity|40|platform_capability|submission_change.changed|-|infrastructure_unavailable|maximum_results=1", - "artifact.scratch.sealed_tree_verified|v1|artifact.scratch.sealed_tree_verified|mandatory_integrity|materialization|10|platform_capability|submission_materialization.sealed_tree_verified|-|infrastructure_unavailable|maximum_results=1", - "submission.packet.required_fields|v1|check_submission_packet|mandatory_accountability|default_policy|10|platform_capability|validate_submission_packet|-|infrastructure_unavailable|maximum_results=1", - "submission.attestation.required_topics|v1|check_confidentiality_attestation|mandatory_accountability|default_policy|20|platform_capability|require_attestation|-|infrastructure_unavailable|maximum_results=1", - "artifact.sensitive_paths.high_confidence|v1|check_forbidden_files|mandatory_security|default_policy|30|platform_capability|forbid_high_confidence_sensitive_path|-|infrastructure_unavailable|maximum_results=1", - "artifact.quality.placeholder_signal|v1|check_low_quality_generated_artifacts|advisory|default_policy|40|platform_capability|warn_low_quality_generated_artifact|-|record_disabled_and_continue|maximum_results=1", - "policy.submission_packet.validate|v1|check_submission_packet|mandatory_accountability|project_policy|10|policy_primitive|validate_submission_packet|validate_submission_packet|infrastructure_unavailable|maximum_results=1", - "policy.storage_scheme.enforce|v1|check_evidence_integrity|mandatory_integrity|project_policy|20|policy_primitive|enforce_storage_scheme|enforce_storage_scheme|infrastructure_unavailable|maximum_results=1", - "policy.manifest_field.require|v1|check_evidence_integrity|mandatory_integrity|project_policy|30|policy_primitive|require_manifest_field|require_manifest_field|infrastructure_unavailable|maximum_results=1", - "policy.hash.verify|v1|check_evidence_integrity|mandatory_integrity|project_policy|40|policy_primitive|verify_hash|verify_hash|infrastructure_unavailable|maximum_results=1", - "policy.file.require|v1|check_required_files|mandatory_accountability|project_policy|50|policy_primitive|require_file|require_file|infrastructure_unavailable|maximum_results=1", - "policy.evidence.minimum|v1|check_evidence_present|mandatory_accountability|project_policy|60|policy_primitive|require_minimum_evidence|require_minimum_evidence|infrastructure_unavailable|maximum_results=1", - "policy.artifact.forbid|v1|check_forbidden_files|mandatory_security|project_policy|70|policy_primitive|forbid_artifact|forbid_artifact|infrastructure_unavailable|maximum_results=1", - "policy.attestation.require|v1|check_confidentiality_attestation|mandatory_accountability|project_policy|80|policy_primitive|require_attestation|require_attestation|infrastructure_unavailable|maximum_results=1", - "policy.file_size.limit|v1|check_evidence_integrity|mandatory_integrity|project_policy|90|policy_primitive|limit_file_size|limit_file_size|infrastructure_unavailable|maximum_results=1", - "policy.package_size.limit|v1|check_evidence_integrity|mandatory_integrity|project_policy|100|policy_primitive|limit_package_size|limit_package_size|infrastructure_unavailable|maximum_results=1", - "policy.archive_entries.limit|v1|check_evidence_integrity|mandatory_integrity|project_policy|105|policy_primitive|limit_archive_entries|limit_archive_entries|infrastructure_unavailable|maximum_results=1", - "policy.archive_size.limit|v1|check_evidence_integrity|mandatory_integrity|project_policy|102|policy_primitive|limit_archive_size|limit_archive_size|infrastructure_unavailable|maximum_results=1", - "policy.packaging.require|v1|check_submission_packet|mandatory_accountability|project_policy|110|policy_primitive|require_packaging|require_packaging|infrastructure_unavailable|maximum_results=1", - "policy.generated_quality.warn|v1|check_low_quality_generated_artifacts|advisory|project_policy|120|policy_primitive|warn_low_quality_generated_artifact|warn_low_quality_generated_artifact|record_disabled_and_continue|maximum_results=1", - } - - assert actual == expected - - -def test_catalogue_manifest_does_not_install_broad_generic_path_heuristics() -> None: - manifest = json.dumps(dict(build_pre_submission_checker_catalogue().manifest), sort_keys=True) - for forbidden_heuristic in ("token*", "secret*", "credential*", "node_modules"): - assert forbidden_heuristic not in manifest - - -def test_catalogue_rejects_unknown_duplicate_and_unsafe_dependency_configuration() -> None: - with pytest.raises(PreSubmissionCatalogueError, match="unknown"): - build_pre_submission_checker_catalogue(disabled_entry_ids=frozenset({"unknown"})) - with pytest.raises(PreSubmissionCatalogueError, match="duplicates"): - parse_disabled_pre_submission_checker_ids( - "artifact.outer_zip.valid,artifact.outer_zip.valid" - ) - - valid = build_pre_submission_checker_catalogue() - first = valid.entries[0] - duplicate = tuple( - sorted( - (*valid.entries, first), key=lambda entry: (entry.phase, entry.order, entry.stable_id) - ) - ) - with pytest.raises(PreSubmissionCatalogueError, match="duplicate stable IDs"): - PreSubmissionCheckerCatalogue( - valid.catalogue_id, valid.version, valid.schema_version, duplicate - ) - - version_alias = tuple( - sorted( - (*valid.entries, replace(first, version="v2")), - key=lambda entry: (entry.phase, entry.order, entry.stable_id), - ) - ) - with pytest.raises(PreSubmissionCatalogueError, match="duplicate stable IDs"): - PreSubmissionCheckerCatalogue( - valid.catalogue_id, valid.version, valid.schema_version, version_alias - ) - - broken = (replace(first, dependencies=("missing.definition",)), *valid.entries[1:]) - with pytest.raises(PreSubmissionCatalogueError, match="dependency is missing"): - PreSubmissionCheckerCatalogue( - valid.catalogue_id, valid.version, valid.schema_version, broken - ) - - -@pytest.mark.parametrize( - ("mutation", "message"), - [ - ({"stable_id": ""}, "identity"), - ({"order": -1}, "order"), - ( - {"dependencies": ("artifact.outer_zip.valid", "artifact.outer_zip.valid")}, - "duplicate dependencies", - ), - ({"typed_inputs": ()}, "typed inputs"), - ({"resource_budget": (("maximum_results", -1),)}, "resource budget"), - ( - {"disabled_behavior": PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE}, - "mandatory catalogue definition may not skip", - ), - ({"dispatch_capability": "unknown.capability"}, "capability is unknown"), - ], -) -def test_catalogue_definition_validation_fails_closed( - mutation: dict[str, object], message: str -) -> None: - definition = build_pre_submission_checker_catalogue().entries[0] - with pytest.raises(PreSubmissionCatalogueError, match=message): - replace(definition, **mutation) - - -def test_catalogue_rejects_noncanonical_order_and_dependency_order() -> None: - catalogue = build_pre_submission_checker_catalogue() - with pytest.raises(PreSubmissionCatalogueError, match="not canonical"): - PreSubmissionCheckerCatalogue( - catalogue.catalogue_id, - catalogue.version, - catalogue.schema_version, - tuple(reversed(catalogue.entries)), - ) - first, second, *rest = catalogue.entries - broken_first = replace(first, dependencies=(second.stable_id,)) - broken = tuple( - sorted( - (broken_first, second, *rest), - key=lambda entry: ( - list(PreSubmissionCheckerPhase).index(entry.phase), - entry.order, - entry.stable_id, - ), - ) - ) - with pytest.raises(PreSubmissionCatalogueError, match="dependency order"): - PreSubmissionCheckerCatalogue( - catalogue.catalogue_id, - catalogue.version, - catalogue.schema_version, - broken, - ) - - -def test_mandatory_disabled_fails_closed_and_advisory_disabled_stays_visible() -> None: - compiled_bundle, lineage = _compiled_and_lineage() - mandatory_disabled = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) - ) - assert mandatory_disabled.available is False - with pytest.raises( - PreSubmissionInfrastructureUnavailableError, - match="pre_submission_infrastructure_unavailable", - ): - compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=_effective_policy(), - compiled_bundle=compiled_bundle, - catalogue=mandatory_disabled, - ) - - advisory_disabled = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) - ) - plan = compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=_effective_policy(), - compiled_bundle=compiled_bundle, - catalogue=advisory_disabled, - ) - entry = next( - item for item in plan.entries if item.definition_id == "artifact.quality.placeholder_signal" - ) - assert entry.checker_definition_state == PreSubmissionCheckerState.DISABLED.value - assert entry.disabled_behavior == "record_disabled_and_continue" - - -def test_effective_plan_is_deterministic_and_commits_to_lineage_catalogue_and_config() -> None: - compiled_bundle, lineage = _compiled_and_lineage() - first = compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=_effective_policy(), - compiled_bundle=compiled_bundle, - catalogue=build_pre_submission_checker_catalogue(), - ) - second = compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=_effective_policy(), - compiled_bundle=compiled_bundle, - catalogue=build_pre_submission_checker_catalogue(), - ) - assert first == second - assert first.plan_sha256 == canonical_json_hash(first.as_dict()) - assert ( - first.catalogue_manifest_sha256 == build_pre_submission_checker_catalogue().manifest_sha256 - ) - assert {entry.definition_id for entry in first.entries}.issuperset( - { - "artifact.outer_zip.valid", - "artifact.manifest.semantic_identity_computed", - "submission.packet.required_fields", - "policy.file.require", - } - ) - assert all(entry.configuration_sha256 for entry in first.entries) - required_file = next( - entry for entry in first.entries if entry.definition_id == "policy.file.require" - ) - exported = required_file.as_dict() - exported["configuration"]["artifact_paths"].append("mutated-after-hash") - assert required_file.as_dict()["configuration"]["artifact_paths"] == ["task.toml"] - assert hash(required_file) - assert first.plan_sha256 == canonical_json_hash(first.as_dict()) - - changed_lineage = replace(lineage, project_id=new_record_id()) - changed = compile_effective_pre_submission_execution_plan( - lineage=changed_lineage, - effective_policy=_effective_policy(), - compiled_bundle=compiled_bundle, - catalogue=build_pre_submission_checker_catalogue(), - ) - assert changed.plan_sha256 != first.plan_sha256 - - advisory_disabled = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) - ) - state_changed = compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=_effective_policy(), - compiled_bundle=compiled_bundle, - catalogue=advisory_disabled, - ) - assert state_changed.plan_sha256 != first.plan_sha256 - first_rule_ids = { - entry.definition_id: entry.rule_instance_id - for entry in first.entries - if entry.rule_instance_id is not None - } - state_changed_rule_ids = { - entry.definition_id: entry.rule_instance_id - for entry in state_changed.entries - if entry.rule_instance_id is not None - } - assert state_changed_rule_ids.keys() == first_rule_ids.keys() - assert all( - state_changed_rule_ids[definition_id] != rule_instance_id - for definition_id, rule_instance_id in first_rule_ids.items() - ) - - -def test_effective_plan_rejects_stale_or_non_catalogue_bundle_facts() -> None: - compiled_bundle, lineage = _compiled_and_lineage() - with pytest.raises(EffectivePreSubmissionPlanError, match="hash mismatch"): - compile_effective_pre_submission_execution_plan( - lineage=replace( - lineage, - pre_submit_policy_bundle_hash="sha256:" + "9" * 64, - ), - effective_policy=_effective_policy(), - compiled_bundle=compiled_bundle, - catalogue=build_pre_submission_checker_catalogue(), - ) - - altered = {**compiled_bundle, "rules": [dict(rule) for rule in compiled_bundle["rules"]]} - altered["rules"][0]["primitive"] = "legacy_alias" - altered_lineage = replace( - lineage, - pre_submit_policy_bundle_hash=canonical_json_hash(altered), - ) - with pytest.raises(EffectivePreSubmissionPlanError, match="unknown"): - compile_effective_pre_submission_execution_plan( - lineage=altered_lineage, - effective_policy=_effective_policy(), - compiled_bundle=altered, - catalogue=build_pre_submission_checker_catalogue(), - ) - - stale_envelope = {**compiled_bundle, "compiler_version": "obsolete"} - with pytest.raises(EffectivePreSubmissionPlanError, match="envelope"): - compile_effective_pre_submission_execution_plan( - lineage=replace( - lineage, - pre_submit_policy_bundle_hash=canonical_json_hash(stale_envelope), - ), - effective_policy=_effective_policy(), - compiled_bundle=stale_envelope, - catalogue=build_pre_submission_checker_catalogue(), - ) - - -def test_effective_plan_rejects_bundle_that_omits_locked_required_rule() -> None: - compiled_bundle, lineage = _compiled_and_lineage() - altered = { - **compiled_bundle, - "rules": [ - dict(rule) - for rule in compiled_bundle["rules"] - if rule["primitive"] != "require_file" - ], - } - altered_lineage = replace( - lineage, - pre_submit_policy_bundle_hash=canonical_json_hash(altered), - ) - - with pytest.raises(EffectivePreSubmissionPlanError, match="locked effective policy"): - compile_effective_pre_submission_execution_plan( - lineage=altered_lineage, - effective_policy=_effective_policy(), - compiled_bundle=altered, - catalogue=build_pre_submission_checker_catalogue(), - ) - - -def test_effective_plan_rejects_policy_body_that_does_not_match_locked_hash() -> None: - compiled_bundle, lineage = _compiled_and_lineage() - weakened_policy = { - **_effective_policy(), - "required_artifacts": [], - } - - with pytest.raises(EffectivePreSubmissionPlanError, match="policy hash mismatch"): - compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=weakened_policy, - compiled_bundle=compiled_bundle, - catalogue=build_pre_submission_checker_catalogue(), - ) - - -def test_effective_plan_lineage_rejects_ambiguous_identity_version_and_hash() -> None: - _, lineage = _compiled_and_lineage() - with pytest.raises(EffectivePreSubmissionPlanError, match="lineage id"): - replace(lineage, project_id="not-a-uuid") - with pytest.raises(EffectivePreSubmissionPlanError, match="guide version"): - replace(lineage, guide_version=0) - with pytest.raises(EffectivePreSubmissionPlanError, match="lineage hash"): - replace(lineage, source_snapshot_hash="sha256:bad") - - -def test_compiler_uses_catalogue_without_durable_checker_registry( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from app.modules.checkers import runner - - monkeypatch.setattr( - runner, - "default_checker_registry", - lambda: (_ for _ in ()).throw(AssertionError("durable registry used")), - ) - compiled = compile_effective_project_submission_artifact_policy( - _effective_policy(), "sha256:" + "3" * 64 - ) - assert "check_evidence_integrity" in compiled.checker_names - assert "check_submission_packet" in compiled.checker_names - - -async def test_application_startup_installs_fixed_catalogue_configuration(clean_postgres_database) -> None: - app = create_app( - Settings( - environment="test", - artifact_pre_submission_checker_disabled_ids="artifact.quality.placeholder_signal", - ) - ) - async with app.router.lifespan_context(app): - catalogue = app.state.pre_submission_checker_catalogue - assert catalogue.definition("artifact.quality.placeholder_signal").state is ( - PreSubmissionCheckerState.DISABLED - ) - - -async def test_application_startup_rejects_unknown_catalogue_configuration() -> None: - app = create_app( - Settings( - environment="test", - artifact_pre_submission_checker_disabled_ids="unknown.definition", - ) - ) - with pytest.raises(PreSubmissionCatalogueError, match="unknown"): - async with app.router.lifespan_context(app): - pass diff --git a/backend/tests/test_checker_output_custody.py b/backend/tests/test_checker_output_custody.py index b039ca851..d42754f1f 100644 --- a/backend/tests/test_checker_output_custody.py +++ b/backend/tests/test_checker_output_custody.py @@ -35,7 +35,7 @@ output_custody_harness, selector_for, ) -from tests.checkers.post_submit.support import request as post_submit_request +from tests.checkers.execution.support import selected_request async def test_default_composition_denies_before_owner_source_or_storage() -> None: @@ -57,7 +57,7 @@ async def source(): yield b"must-not-be-read" selector = CheckerOutputSelector( - evaluation=post_submit_request(), + evaluation=selected_request(), checker_run_id=new_record_id(), worker_lease_id=new_record_id(), worker_lease_generation=1, diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py deleted file mode 100644 index d71bf137a..000000000 --- a/backend/tests/test_checkers.py +++ /dev/null @@ -1,1186 +0,0 @@ -# pyright: reportAttributeAccessIssue=false -from __future__ import annotations - -from collections.abc import AsyncIterator, Iterator -from copy import deepcopy -import math -from pathlib import Path -from typing import Any, cast - -import pytest -from alembic.config import Config -from httpx import ASGITransport, AsyncClient -from pydantic import TypeAdapter, ValidationError -from sqlalchemy import inspect, select - -from app.core.config import get_settings -from app.core.hashing import canonical_json_hash -from app.db import models as db_models -from app.db import session as db_session -from app.db.base import Base -from app.main import create_app -from app.modules.checkers import compiler as checker_compiler_module -from app.modules.checkers.compiler import ( - PRE_SUBMIT_COMPILER_VERSION, - PreSubmitCheckerCompilerError, - build_project_pre_submit_checker_spec, - compile_effective_project_submission_artifact_policy, - compile_project_pre_submit_checker_spec, - validate_compiled_pre_submit_checker_bundle, -) -from app.modules.checkers.models import CheckerResult, CheckerRun -from app.modules.checkers import runner as checker_runner_module -from app.modules.checkers.pre_submit_defaults import attestation_term_is_satisfied -from app.modules.checkers.runner import ( - CheckerContext, - CheckerNameConflict, - CheckerOutcome, - CheckerRegistry, - FunctionChecker, - UnknownChecker, - canonical_artifact_manifest_hash, -) -from app.modules.checkers.api.history import CheckerRoutingRecommendation -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue -from app.modules.projects.post_submit_policy import ( - DEFAULT_DURABLE_CHECKERS, - POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, - PostSubmitCheckerCompilerError, - build_project_post_submit_checker_spec, - compile_project_post_submit_checker_spec, - parse_locked_post_submit_checker_policy_body, -) -from app.modules.tasks.models import AuditEvent, EvidenceItem, Submission, WorkstreamTask -from tests.test_tasks import ( - auth_headers, - set_dev_actor, -) -from project_create_fixtures import ( - grant_system_project_manager, -) - - -@pytest.fixture -def checker_database_env( - monkeypatch: pytest.MonkeyPatch, - clean_postgres_database: str, -) -> Iterator[str]: - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) - monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") - monkeypatch.setenv( - "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", - "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - ) - set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - get_settings.cache_clear() - try: - yield clean_postgres_database - finally: - get_settings.cache_clear() - - -@pytest.fixture -async def checker_client(checker_database_env: str) -> AsyncIterator[AsyncClient]: - app = create_app() - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://testserver", - ) as client: - admission = await client.get("/api/v1/actors/me", headers=auth_headers()) - assert admission.status_code == 200, admission.text - async with db_session.get_session_factory()() as session: - await grant_system_project_manager( - session, - issuer="flow-test", - subject="project-manager-subject", - ) - await session.commit() - yield client - - -def alembic_config() -> Config: - project_root = Path(__file__).resolve().parents[1] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "alembic")) - return config - - -async def task_side_effect_snapshot(task_id: str) -> dict: - """Capture durable task-scoped rows that denied requests must not change.""" - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, task_id) - submissions = ( - await session.scalars( - select(Submission).where(Submission.task_id == task_id).order_by(Submission.id) - ) - ).all() - submission_ids = [submission.id for submission in submissions] - evidence_items = [] - if submission_ids: - evidence_items = ( - await session.scalars( - select(EvidenceItem) - .where(EvidenceItem.submission_id.in_(submission_ids)) - .order_by(EvidenceItem.id) - ) - ).all() - checker_runs = ( - await session.scalars( - select(CheckerRun).where(CheckerRun.task_id == task_id).order_by(CheckerRun.id) - ) - ).all() - checker_results = ( - await session.scalars( - select(CheckerResult) - .where(CheckerResult.task_id == task_id) - .order_by(CheckerResult.id) - ) - ).all() - audit_events = ( - await session.scalars( - select(AuditEvent) - .where(AuditEvent.entity_type == "task", AuditEvent.entity_id == task_id) - .order_by(AuditEvent.id) - ) - ).all() - submission_audit_events = [] - if submission_ids: - submission_audit_events = ( - await session.scalars( - select(AuditEvent) - .where( - AuditEvent.entity_type == "submission", - AuditEvent.entity_id.in_(submission_ids), - ) - .order_by(AuditEvent.id) - ) - ).all() - return { - "task_status": None if task is None else task.status, - "task_assigned_to": None if task is None else task.assigned_to, - "submissions": [ - ( - submission.id, - submission.version, - submission.status, - submission.package_hash, - submission.supersedes_submission_id, - ) - for submission in submissions - ], - "evidence_items": [ - (item.id, item.submission_id, item.type, item.hash) for item in evidence_items - ], - "checker_runs": [ - ( - run.id, - run.submission_id, - run.submission_version, - run.attempt_number, - run.routing_recommendation, - run.status, - run.is_current_for_submission, - run.failure_code, - run.failure_message, - run.queued_at, - run.started_at, - run.completed_at, - run.created_at, - ) - for run in checker_runs - ], - "checker_results": [ - (result.id, result.checker_run_id, result.checker_name, result.status) - for result in checker_results - ], - "audit_events": [ - ( - event.id, - event.event_type, - event.from_status, - event.to_status, - event.event_payload, - ) - for event in audit_events - ], - "submission_audit_events": [ - ( - event.id, - event.entity_id, - event.event_type, - event.from_status, - event.to_status, - event.event_payload, - ) - for event in submission_audit_events - ], - } - - -def _current_locked_test_facts() -> dict: - """Supply real current lock types to orchestration tests.""" - from tests.checkers.post_submit.support import request - - facts = request().expected_context.model_dump(mode="json") - return { - "locked_guide_version": facts["guide_version"], - "locked_guide_source_snapshot_id": facts["source_id"], - "locked_guide_source_snapshot_hash": facts["source_hash"], - "locked_effective_project_submission_artifact_policy_id": facts["effective_policy_id"], - "locked_effective_project_submission_artifact_policy_hash": facts["effective_policy_hash"], - "locked_pre_submit_checker_policy_id": facts["pre_policy_id"], - "locked_pre_submit_checker_bundle_hash": facts["pre_policy_hash"], - "locked_post_submit_checker_policy_id": facts["post_policy_id"], - "locked_post_submit_checker_policy_version": facts["post_policy_version"], - "locked_post_submit_checker_policy_hash": facts["post_policy_hash"], - "locked_review_policy_id": facts["review_policy_id"], - "locked_review_policy_generation": facts["review_generation"], - "locked_review_policy_hash": facts["review_hash"], - "locked_revision_policy_id": facts["revision_policy_id"], - "locked_revision_policy_generation": facts["revision_generation"], - "locked_revision_policy_hash": facts["revision_hash"], - } - - -def _canonical_test_post_policy(): - project_id = "11111111-1111-4111-8111-111111111111" - return compile_project_post_submit_checker_spec( - project_id=project_id, - guide_version="v1", - spec=build_project_post_submit_checker_spec(project_id=project_id, guide_version="v1"), - ) - - -def _validate_test_post_body(body): - parsed = parse_locked_post_submit_checker_policy_body( - body, - project_id=body["project_id"], - guide_version=body["guide_version"], - policy_hash=canonical_json_hash(body), - ) - - parsed.validate_catalogue(current_post_submit_catalogue()) - return parsed - - -def test_locked_post_submit_policy_parser_uses_persisted_body_hash() -> None: - compiled = _canonical_test_post_policy() - parsed = parse_locked_post_submit_checker_policy_body( - compiled.policy_body, project_id=str(compiled.project_id), - guide_version=compiled.guide_version, policy_hash=compiled.policy_hash, - ) - assert parsed == compiled - with pytest.raises(ValueError, match="policy hash is invalid"): - parse_locked_post_submit_checker_policy_body( - compiled.policy_body, project_id=str(compiled.project_id), - guide_version=compiled.guide_version, policy_hash="sha256:" + "0" * 64, - ) - - -def test_post_submit_compiler_accepts_default_only_policy() -> None: - spec = build_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - ) - - compiled = compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - assert compiled.compiler_version == "workstream-post-submit-compiler" - assert compiled.policy_body["compiler_version"] == compiled.compiler_version - assert compiled.required_checkers == [] - assert compiled.warning_checkers == [] - assert compiled.execution_checkers == DEFAULT_DURABLE_CHECKERS - assert compiled.default_checkers == DEFAULT_DURABLE_CHECKERS - assert [entry["checker_id"] for entry in compiled.policy_body["entries"]] == DEFAULT_DURABLE_CHECKERS - assert compiled.policy_hash == canonical_json_hash(compiled.policy_body) - assert list(compiled.blocking_severities) == ["critical", "high"] - - -def test_post_submit_compiler_canonicalizes_project_specific_checker_spec() -> None: - spec = build_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - required_checkers=[ - "check_acceptance_criteria_present", - ], - warning_checkers=[], - blocking_severities=["critical", "high", "medium"], - ) - - compiled = compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - assert compiled.required_checkers == [ - "check_acceptance_criteria_present", - ] - assert compiled.execution_checkers == [ - *DEFAULT_DURABLE_CHECKERS, - "check_acceptance_criteria_present", - ] - assert list(compiled.blocking_severities) == ["critical", "high", "medium"] - - -def test_post_submit_compiler_rejects_unknown_checker_name() -> None: - spec = build_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - required_checkers=["missing_checker"], - ) - - with pytest.raises(PostSubmitCheckerCompilerError, match="selection is unavailable"): - compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - -def test_post_submit_compiler_rejects_non_object_spec() -> None: - spec: Any = [] - - with pytest.raises(PostSubmitCheckerCompilerError, match="spec shape"): - compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - -def test_post_submit_compiler_rejects_tuple_spec_lists() -> None: - spec = { - "schema_version": POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, - "project_id": "11111111-1111-4111-8111-111111111111", - "guide_version": "v1", - "required_checkers": ("check_acceptance_criteria_present",), - "warning_checkers": [], - "blocking_severities": ["critical", "high"], - } - - with pytest.raises(PostSubmitCheckerCompilerError, match="required_checkers"): - compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - -def test_post_submit_compiler_rejects_duplicate_checker_names() -> None: - spec = { - "schema_version": POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, - "project_id": "11111111-1111-4111-8111-111111111111", - "guide_version": "v1", - "required_checkers": [ - "check_acceptance_criteria_present", - "check_acceptance_criteria_present", - ], - "warning_checkers": [], - "blocking_severities": ["critical", "high"], - } - - with pytest.raises(PostSubmitCheckerCompilerError, match="duplicate checker"): - compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - -def test_post_submit_compiler_rejects_conflicting_checker_classification() -> None: - spec = { - "schema_version": POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, - "project_id": "11111111-1111-4111-8111-111111111111", - "guide_version": "v1", - "required_checkers": ["check_acceptance_criteria_present"], - "warning_checkers": ["check_acceptance_criteria_present"], - "blocking_severities": ["critical", "high"], - } - - with pytest.raises(PostSubmitCheckerCompilerError, match="conflicting checker"): - compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - -def test_post_submit_compiler_rejects_warning_only_default_checker_override() -> None: - with pytest.raises(PostSubmitCheckerCompilerError, match="default checkers"): - build_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - warning_checkers=["check_submission_packet"], - ) - - -def test_post_submit_compiler_rejects_raw_spec_warning_only_default_checker_override() -> None: - spec = { - "schema_version": POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, - "project_id": "11111111-1111-4111-8111-111111111111", - "guide_version": "v1", - "required_checkers": [], - "warning_checkers": ["check_submission_packet"], - "blocking_severities": ["critical", "high"], - } - - with pytest.raises(PostSubmitCheckerCompilerError, match="default checkers"): - compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - -def test_post_submit_compiler_rejects_raw_spec_blocking_severity_downgrade() -> None: - spec = { - "schema_version": POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, - "project_id": "11111111-1111-4111-8111-111111111111", - "guide_version": "v1", - "required_checkers": [], - "warning_checkers": [], - "blocking_severities": ["high"], - } - - with pytest.raises(PostSubmitCheckerCompilerError, match="blocking severities"): - compile_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - spec=spec, - ) - - -@pytest.mark.parametrize( - "blocking_severities", - [[], ["critical"], ["high"]], -) -def test_post_submit_compiler_rejects_blocking_severity_downgrade( - blocking_severities: list[str], -) -> None: - with pytest.raises(PostSubmitCheckerCompilerError, match="blocking severities"): - build_project_post_submit_checker_spec( - project_id="11111111-1111-4111-8111-111111111111", - guide_version="v1", - blocking_severities=blocking_severities, - ) - - -@pytest.mark.parametrize( - "default_checkers", - [ - list(DEFAULT_DURABLE_CHECKERS[:-1]), - list(reversed(DEFAULT_DURABLE_CHECKERS)), - [*DEFAULT_DURABLE_CHECKERS[:-1], "renamed_default_checker"], - [*DEFAULT_DURABLE_CHECKERS, "extra_default_checker"], - ], -) -def test_locked_post_submit_policy_validation_rejects_default_checker_drift(default_checkers: list[str]) -> None: - body = _canonical_test_post_policy().policy_body - by_name = {entry["checker_id"]: entry for entry in body["entries"]} - template = body["entries"][0] - body["entries"] = [by_name.get(name, {**template, "checker_id": name}) for name in default_checkers] - with pytest.raises(ValueError): - _validate_test_post_body(body) - - -@pytest.mark.parametrize( - "drifted_defaults", - [ - list(DEFAULT_DURABLE_CHECKERS[:-1]), - list(reversed(DEFAULT_DURABLE_CHECKERS)), - [*DEFAULT_DURABLE_CHECKERS[:-1], "renamed_default_checker"], - [*DEFAULT_DURABLE_CHECKERS, "extra_default_checker"], - ], -) -def test_locked_post_submit_policy_validation_rejects_self_consistent_default_drift(drifted_defaults: list[str]) -> None: - body = _canonical_test_post_policy().policy_body - template = body["entries"][0] - body["entries"] = [{**template, "checker_id": name} for name in drifted_defaults] - # Recomputed digest proves the canonical catalogue rejects changed defaults. - with pytest.raises(ValueError): - _validate_test_post_body(body) - - -def test_locked_post_submit_policy_validation_rejects_unsupported_compiler_version() -> None: - body = _canonical_test_post_policy().policy_body - body["compiler_version"] = "unsupported" - with pytest.raises(ValueError, match="compiler_version"): - _validate_test_post_body(body) - - -@pytest.mark.parametrize( - ("required_checkers", "warning_checkers", "execution_checkers"), - [ - ( - ["check_acceptance_criteria_present"], - ["check_acceptance_criteria_present"], - [*DEFAULT_DURABLE_CHECKERS, "check_acceptance_criteria_present"], - ), - ([], ["check_submission_packet"], list(DEFAULT_DURABLE_CHECKERS)), - ], -) -def test_locked_post_submit_policy_validation_rejects_conflicting_classifications( - required_checkers: list[str], warning_checkers: list[str], execution_checkers: list[str], -) -> None: - body = _canonical_test_post_policy().policy_body - template = {**body["entries"][0], "checker_id": "check_acceptance_criteria_present"} - if required_checkers: - body["entries"] = body["entries"][:-1] + [{**template, "classification": "project_required"}, - {**template, "classification": "project_warning"}] - else: - body["entries"][0]["classification"] = "project_warning" - with pytest.raises(ValueError, match="duplicate entries|classification mismatch"): - _validate_test_post_body(body) - - -@pytest.mark.parametrize( - "blocking_severities", - [[], ["critical"], ["high"]], -) -def test_locked_post_submit_policy_validation_rejects_blocking_severity_downgrade(blocking_severities: list[str]) -> None: - body = _canonical_test_post_policy().policy_body - body["blocking_severities"] = blocking_severities - with pytest.raises(ValueError, match="blocking_severities"): - _validate_test_post_body(body) - - -def test_checker_models_are_registered_for_alembic_metadata() -> None: - expected_tables = {"checker_runs", "checker_results"} - - assert expected_tables.issubset(Base.metadata.tables) - assert db_models.CheckerRun is CheckerRun - assert db_models.CheckerResult is CheckerResult - - -def test_checker_routing_recommendation_schema_uses_canonical_routing_tokens() -> None: - adapter = TypeAdapter(CheckerRoutingRecommendation) - - for token in ("not_evaluated", "allow_review", "needs_revision", "task_setup_blocked"): - assert adapter.validate_python(token) == token - with pytest.raises(ValidationError): - adapter.validate_python("checker_retry") - with pytest.raises(ValidationError): - adapter.validate_python("operator" + "_retry") - - -async def test_checker_migration_creates_expected_tables(checker_database_env: str) -> None: - async with db_session.get_engine().connect() as connection: - table_names = await connection.run_sync( - lambda sync_connection: set(inspect(sync_connection).get_table_names()) - ) - - assert {"checker_runs", "checker_results"}.issubset(table_names) - - -def test_checker_run_binds_to_locked_post_submit_policy_context() -> None: - expected_constraints = { - "fk_checker_runs_locked_post_submit_policy_hash": [ - "locked_post_submit_checker_policy_id", - "locked_post_submit_checker_policy_version", - "locked_post_submit_checker_policy_hash", - ], - "fk_checker_runs_submission_locked_post_submit_policy_hash": [ - "submission_id", - "locked_post_submit_checker_policy_id", - "locked_post_submit_checker_policy_version", - "locked_post_submit_checker_policy_hash", - ], - } - - for constraint_name, local_columns in expected_constraints.items(): - constraint = next( - constraint - for constraint in CheckerRun.__table__.foreign_key_constraints - if constraint.name == constraint_name - ) - assert [column.name for column in constraint.columns] == local_columns - for name in expected_constraints["fk_checker_runs_locked_post_submit_policy_hash"]: - assert CheckerRun.__table__.c[name].nullable is False - - -def test_artifact_manifest_hash_is_stable_and_rejects_duplicates() -> None: - first = [ - {"artifact": "b.txt", "hash": "sha256:b", "size_bytes": 2, "notes": None}, - {"hash": "sha256:a", "artifact": "a.txt", "notes": "main", "size_bytes": 1}, - ] - second = [ - {"size_bytes": 1, "notes": "main", "artifact": "a.txt", "hash": "sha256:a"}, - {"notes": None, "artifact": "b.txt", "hash": "sha256:b", "size_bytes": 2}, - ] - - assert canonical_artifact_manifest_hash(first) == canonical_artifact_manifest_hash(second) - - with pytest.raises(ValueError, match="duplicate artifact"): - canonical_artifact_manifest_hash( - [ - {"artifact": "a.txt", "hash": "sha256:a"}, - {"artifact": "a.txt", "hash": "sha256:b"}, - ] - ) - - -def compiler_effective_policy() -> dict: - """Return a minimal effective project policy for compiler tests.""" - default_policy = { - "required_packet_fields": ["summary", "artifact_hash_manifest", "worker_attestation"], - "required_artifacts": [], - "required_evidence": [], - "forbidden_artifacts": [ - {"pattern": ".env", "source": "workstream_default", "severity": "blocking"}, - ], - "attestation_terms": ["original_work"], - "manifest_required": True, - "artifact_hash_required": True, - "artifact_hash_algorithm": "sha256", - "allowed_storage_schemes": ["local", "s3", "r2"], - "maximum_file_size_bytes": None, - "maximum_package_size_bytes": None, - "packaging": {}, - } - project_policy = { - "schema_version": "project_submission_artifact_policy.v1", - "required_artifacts": [ - { - "key": "answer", - "path": "outputs/answer.md", - "hash_required": True, - "required": True, - "description": "Answer artifact.", - } - ], - "required_evidence": [ - { - "key": "work_evidence", - "label": "Work evidence", - "hash_required": True, - "required": True, - "description": "Evidence for the answer.", - } - ], - "forbidden_artifacts": [ - {"pattern": "*.tmp", "reason": "Temporary files are not reviewable."}, - ], - "attestation_terms": ["project_specific_originality"], - "manifest_required": True, - "artifact_hash_required": True, - "artifact_hash_algorithm": "sha256", - "allowed_storage_schemes": ["local", "s3", "r2"], - "maximum_file_size_bytes": 1_000_000, - "maximum_package_size_bytes": 5_000_000, - "packaging": {"package_required": False}, - } - return { - "schema_version": "effective_project_submission_artifact_policy.v1", - "merge_algorithm_version": "workstream_default_merge.v1", - "workstream_default_policy": default_policy, - "project_policy": project_policy, - "required_packet_fields": default_policy["required_packet_fields"], - "required_artifacts": project_policy["required_artifacts"], - "required_evidence": project_policy["required_evidence"], - "forbidden_artifacts": [ - *default_policy["forbidden_artifacts"], - *project_policy["forbidden_artifacts"], - ], - "attestation_terms": [ - *default_policy["attestation_terms"], - *project_policy["attestation_terms"], - ], - "manifest_required": True, - "artifact_hash_required": True, - "artifact_hash_algorithm": "sha256", - "allowed_storage_schemes": ["local", "s3", "r2"], - "maximum_file_size_bytes": 1_000_000, - "maximum_package_size_bytes": 5_000_000, - "packaging": {"package_required": False}, - } - - -def test_pre_submit_compiler_emits_stable_approved_project_bundle() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "1" * 64 - - first = compile_effective_project_submission_artifact_policy( - effective_policy, - effective_policy_hash, - ) - second = compile_effective_project_submission_artifact_policy( - effective_policy, - effective_policy_hash, - ) - - assert first.compiler_version == PRE_SUBMIT_COMPILER_VERSION - assert first.compiled_bundle == second.compiled_bundle - assert first.compiled_bundle_hash == second.compiled_bundle_hash - assert first.compiled_bundle["effective_policy_hash"] == effective_policy_hash - assert { - "validate_submission_packet", - "require_manifest_field", - "verify_hash", - "require_file", - "require_minimum_evidence", - "forbid_artifact", - "require_attestation", - }.issubset({rule["primitive"] for rule in first.compiled_bundle["rules"]}) - assert "check_required_files" in first.checker_names - assert "check_evidence_present" in first.checker_names - - -def test_pre_submit_compiler_rejects_unknown_primitive() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "2" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - spec["rules"].append( - { - "primitive": "run_arbitrary_python", - "severity": "blocking", - "policy_fields": ["required_artifacts"], - "config": {}, - } - ) - - with pytest.raises(PreSubmitCheckerCompilerError, match="unknown primitive"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_omitted_required_artifact_coverage() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "3" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - spec["rules"] = [rule for rule in spec["rules"] if rule["primitive"] != "require_file"] - - with pytest.raises(PreSubmitCheckerCompilerError, match="require_file"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_skipped_evidence_coverage() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "4" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "require_minimum_evidence": - rule["config"]["evidence_paths"] = [] - - with pytest.raises(PreSubmitCheckerCompilerError, match="required evidence"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_weakened_default_severity() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "5" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "verify_hash": - rule["severity"] = "warning" - - with pytest.raises(PreSubmitCheckerCompilerError, match="weakens severity"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_escalated_warning_only_rule() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "c" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "warn_low_quality_generated_artifact": - rule["severity"] = "blocking" - - with pytest.raises(PreSubmitCheckerCompilerError, match="warning-only"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_configured_warning_only_rule() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "c" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "warn_low_quality_generated_artifact": - rule["config"] = {"threshold": "strict"} - - with pytest.raises(PreSubmitCheckerCompilerError, match="warning-only rule"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_canonical_json_hash_rejects_non_finite_numbers() -> None: - with pytest.raises(ValueError): - canonical_json_hash({"score": math.nan}) - - -def test_pre_submit_compiler_rejects_missing_workstream_defaults() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "6" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "forbid_artifact": - rule["config"]["patterns"] = ["*.tmp"] - - with pytest.raises(PreSubmitCheckerCompilerError, match="forbidden artifacts"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_untraceable_policy_fields() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "7" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "require_file": - rule["policy_fields"] = ["required_artifacts", "operator_override"] - - with pytest.raises(PreSubmitCheckerCompilerError, match="untraceable policy fields"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_weakened_size_limits() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "8" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "limit_file_size": - rule["config"]["maximum_file_size_bytes"] = 2_000_000 - - with pytest.raises(PreSubmitCheckerCompilerError, match="file size"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_weakened_package_limits() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "9" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "limit_package_size": - rule["config"]["maximum_package_size_bytes"] = 6_000_000 - - with pytest.raises(PreSubmitCheckerCompilerError, match="package size"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_weakened_packaging_config() -> None: - effective_policy = compiler_effective_policy() - effective_policy["packaging"] = { - "package_required": True, - "allowed_package_formats": ["zip"], - } - effective_policy_hash = "sha256:" + "a" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - for rule in spec["rules"]: - if rule["primitive"] == "require_packaging": - rule["config"]["package_required"] = False - - with pytest.raises(PreSubmitCheckerCompilerError, match="packaging"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_pre_submit_compiler_rejects_untraceable_extra_rules() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "b" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - spec["rules"].append( - { - "primitive": "require_packaging", - "severity": "blocking", - "policy_fields": ["packaging"], - "config": {"package_required": True}, - } - ) - - with pytest.raises(PreSubmitCheckerCompilerError, match="untraceable primitive"): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_manifest_hash_rejects_incomplete_entries() -> None: - for manifest in ([{"artifact": "answer.md"}], [{"hash": "sha256:a"}]): - with pytest.raises(ValueError, match="require artifact and hash"): - canonical_artifact_manifest_hash(manifest) - - -def test_evidence_integrity_reports_each_untrusted_reference_shape() -> None: - traversal = checker_runner_module._evidence_integrity_outcome( - [{"artifact": "../secret.txt", "hash": "sha256:a"}], - [], - ) - malformed_hash = checker_runner_module._evidence_integrity_outcome( - [{"artifact": "answer.md", "hash": "md5:a"}], - [], - ) - missing_evidence_hash = checker_runner_module._evidence_integrity_outcome( - [{"artifact": "answer.md", "hash": "sha256:a"}], - [{"label": "proof", "uri": "s3://bucket/proof", "hash": None}], - ) - - assert traversal.blocks_review is True - assert "integrity_error" in traversal.metadata - assert malformed_hash.metadata == {"invalid_artifact_count": 1} - assert missing_evidence_hash.metadata == {"missing_evidence_hash_count": 1} - - -def test_required_file_checker_handles_empty_invalid_and_missing_contracts() -> None: - no_requirement = checker_runner_module._required_files_outcome([], []) - invalid = checker_runner_module._required_files_outcome(["../answer.md"], []) - missing = checker_runner_module._required_files_outcome( - ["answer.md"], - [{"artifact": "notes.md", "hash": "sha256:notes"}], - ) - - assert no_requirement.status == "passed" - assert invalid.blocks_review is True - assert "relative artifact paths" in (invalid.worker_suggested_fix or "") - assert missing.metadata == {"missing_required_files": ["answer.md"]} - - -def test_forbidden_file_checker_classifies_without_leaking_paths() -> None: - outcome = checker_runner_module._forbidden_files_outcome( - [ - {"artifact": "../ignored"}, - {"artifact": "config/.env"}, - {"artifact": "keys/client.pem"}, - {"artifact": "build/debug.tmp"}, - ], - [], - ["*.tmp"], - ) - - assert outcome.blocks_review is True - assert outcome.metadata == { - "forbidden_categories": [ - "forbidden_file_suffix", - "forbidden_path_segment", - "forbidden_policy_pattern", - ] - } - assert "client.pem" not in outcome.message - - -def test_packet_shape_reports_all_required_fields_without_echoing_values() -> None: - outcome = checker_runner_module._packet_shape_outcome("", "", []) - - assert outcome.blocks_review is True - assert outcome.metadata == { - "missing_fields": ["summary", "package_hash", "artifact_hash_manifest"] - } - - -def test_artifact_path_and_pattern_normalization_fail_closed() -> None: - assert checker_runner_module._normalize_artifact_path("./folder//answer.md") == ( - "folder/answer.md" - ) - with pytest.raises(ValueError, match="relative and non-empty"): - checker_runner_module._normalize_artifact_path("/absolute.txt") - assert not checker_runner_module._path_matches_forbidden_pattern("answer.md", "") - - -@pytest.mark.asyncio -async def test_policy_context_checker_blocks_incomplete_lock_without_exposing_details() -> None: - from dataclasses import replace - from app.modules.checkers.api import ObservedPostSubmitContext - from app.modules.checkers.post_submit_implementations import detached_checker_context - from tests.checkers.post_submit.support import request - source = request() - context = detached_checker_context(source) - assert (await checker_runner_module.check_policy_context_present(context)).status == "passed" - observed = source.structural_input.observed_context.model_dump() - observed["pre_policy_id"] = None - outcome = await checker_runner_module.check_policy_context_present( - replace(context, observed_context=ObservedPostSubmitContext(**observed)) - ) - assert outcome.status == "failed" - assert outcome.worker_visible is False - assert outcome.routing_recommendation == "task_setup_blocked" - assert outcome.metadata == {"invalid_context": ["pre_policy_id"]} - - -def test_attestation_and_policy_projection_helpers_preserve_required_only_rules() -> None: - policy = { - "required_artifacts": [ - {"path": "answer.md", "required": True}, - {"path": "optional.md", "required": False}, - ], - "required_evidence": [ - {"key": "proof", "required": True}, - {"key": "optional", "required": False}, - ], - "forbidden_artifacts": [{"pattern": "*.key"}, {"pattern": ""}], - "attestation_terms": ["original work", ""], - } - - assert checker_runner_module._required_artifact_paths(policy) == ["answer.md"] - assert checker_runner_module._required_evidence_keys(policy) == ["proof"] - assert checker_runner_module._forbidden_artifact_patterns(policy) == ["*.key"] - assert checker_runner_module._required_attestation_terms(policy) == ["original work"] - assert checker_runner_module._required_artifact_paths(None) == [] - assert checker_runner_module._required_evidence_keys(None) == [] - assert checker_runner_module._forbidden_artifact_patterns(None) == [] - assert checker_runner_module._required_attestation_terms(None) == [] - assert attestation_term_is_satisfied("anything", "") - assert attestation_term_is_satisfied( - "i_confirm_original_work", - "original work", - ) - assert not attestation_term_is_satisfied( - "i_confirm_original_work", - "client confidentiality", - ) - - -@pytest.mark.asyncio -async def test_checker_registry_preserves_policy_order_and_rejects_name_drift() -> None: - from app.modules.checkers.api.post_submit_catalogue import structural_definition - registry = CheckerRegistry() - async def outcome(name: str) -> CheckerOutcome: - return CheckerOutcome(name, "passed", "info", f"{name} passed") - policy = _canonical_test_post_policy() - for name in policy.execution_checkers: - registry.register(FunctionChecker(name, lambda _context, name=name: outcome(name)), - definition=structural_definition(name)) - results = await registry.run(cast(CheckerContext, object()), policy.entries) - assert [result.checker_name for result in results] == policy.execution_checkers - name = policy.execution_checkers[0] - with pytest.raises(CheckerNameConflict, match="already registered"): - registry.register(FunctionChecker(name, lambda _context: outcome(name)), - definition=structural_definition(name)) - with pytest.raises(UnknownChecker, match="missing, unknown"): - registry.require_registered({"unknown", "missing"}) - - -@pytest.mark.parametrize( - ("mutation", "message"), - [ - ({"schema_version": "invalid"}, "schema version"), - ({"compiler_version": "invalid"}, "compiler version"), - ({"primitives_version": "invalid"}, "primitives version"), - ({"effective_policy_hash": "sha256:other"}, "policy hash mismatch"), - ({"rules": []}, "requires rules"), - ], -) -def test_compiled_checker_bundle_rejects_envelope_drift( - mutation: dict[str, Any], - message: str, -) -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "d" * 64 - compiled = compile_effective_project_submission_artifact_policy( - effective_policy, - effective_policy_hash, - ) - bundle = deepcopy(compiled.compiled_bundle) - bundle.update(mutation) - - with pytest.raises(PreSubmitCheckerCompilerError, match=message): - validate_compiled_pre_submit_checker_bundle( - effective_policy, - effective_policy_hash, - bundle, - compiler_version=compiled.compiler_version, - ) - - -@pytest.mark.parametrize( - ("mutation", "message"), - [ - ({"schema_version": "invalid"}, "schema version"), - ({"effective_policy_hash": "sha256:other"}, "policy hash mismatch"), - ({"rules": []}, "requires rules"), - ], -) -def test_checker_spec_rejects_envelope_drift( - mutation: dict[str, Any], - message: str, -) -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "e" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - spec.update(mutation) - - with pytest.raises(PreSubmitCheckerCompilerError, match=message): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -@pytest.mark.parametrize( - ("mutation", "message"), - [ - ({"severity": "low"}, "invalid severity"), - ({"policy_fields": []}, "lacks policy trace"), - ({"policy_fields": [None]}, "invalid policy trace"), - ({"config": []}, "config must be an object"), - ], -) -def test_checker_spec_rejects_ambiguous_rule_shape( - mutation: dict[str, Any], - message: str, -) -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "f" * 64 - spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - spec["rules"][0].update(mutation) - - with pytest.raises(PreSubmitCheckerCompilerError, match=message): - compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) - - -def test_checker_spec_rejects_duplicate_primitive_and_hash_algorithm_drift() -> None: - effective_policy = compiler_effective_policy() - effective_policy_hash = "sha256:" + "1" * 64 - duplicate = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - duplicate["rules"].append(deepcopy(duplicate["rules"][0])) - - with pytest.raises(PreSubmitCheckerCompilerError, match="duplicate primitive"): - compile_project_pre_submit_checker_spec( - effective_policy, - effective_policy_hash, - duplicate, - ) - - hash_drift = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - next(rule for rule in hash_drift["rules"] if rule["primitive"] == "verify_hash")[ - "config" - ]["algorithm"] = "md5" - with pytest.raises(PreSubmitCheckerCompilerError, match="hash algorithm"): - compile_project_pre_submit_checker_spec( - effective_policy, - effective_policy_hash, - hash_drift, - ) - - -@pytest.mark.parametrize( - ("field", "value", "message"), - [ - ("packaging", [], "packaging must be an object"), - ("required_artifacts", {}, "required_artifacts must be a list"), - ("required_evidence", ["proof"], "required_evidence entries are invalid"), - ], -) -def test_checker_policy_rejects_non_executable_collection_shapes( - field: str, - value: Any, - message: str, -) -> None: - policy = compiler_effective_policy() - policy[field] = value - - with pytest.raises(PreSubmitCheckerCompilerError, match=message): - checker_compiler_module._expected_primitives(policy) - - -@pytest.mark.parametrize( - "old_checker_name", - ["check_evidence_references_present", "check_artifact_manifest_integrity"], -) -def test_old_checker_name_blocks_post_submit_compilation_without_alias( - old_checker_name: str, -) -> None: - spec = build_project_post_submit_checker_spec( - project_id="00000000-0000-0000-0000-000000000001", - guide_version="v1", - required_checkers=[old_checker_name], - ) - - with pytest.raises(PostSubmitCheckerCompilerError, match="post-submit project selection is unavailable"): - compile_project_post_submit_checker_spec( - project_id="00000000-0000-0000-0000-000000000001", - guide_version="v1", - spec=spec, - ) diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 60d00af4b..bfdf1ae47 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -44,13 +44,21 @@ def test_committed_lanes_cover_recursive_inventory_exactly_once() -> None: def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> None: delivery = { + "tests/checkers/default/test_contract.py", + "tests/checkers/default/test_execution.py", "tests/checkers/external/test_contracts.py", "tests/checkers/external/test_migration.py", "tests/checkers/external/test_postgresql.py", "tests/checkers/external/test_registry.py", "tests/checkers/external/test_runtime.py", - "tests/checkers/execution/test_results.py", + "tests/checkers/external/test_policy.py", + "tests/checkers/external/test_selected_post_submit.py", + "tests/checkers/external/test_selected_pre_submit.py", + "tests/checkers/external/test_selected_pre_submit_coordination.py", + "tests/checkers/external/test_selected_pre_submit_service.py", + "tests/checkers/external/test_selected_preparation_command.py", "tests/checkers/execution/test_execution.py", + "tests/checkers/execution/test_completion_evidence.py", "tests/checkers/execution/test_coordination.py", "tests/checkers/execution/test_concurrency.py", "tests/checkers/execution/test_storage.py", @@ -63,7 +71,6 @@ def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> "tests/checkers/execution/test_checker_retry_migration.py", "tests/test_post_submit_materialization.py", "tests/test_post_submit_selection.py", - "tests/test_submission_evaluation_capacity.py", "tests/test_checker_output_custody.py", "tests/test_checker_output_storage.py", } @@ -190,7 +197,6 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/projects/guide_compilation/finalization/test_structure.py", "tests/projects/guide_compilation/proposals/test_approved_tip.py", "tests/projects/guide_compilation/proposals/test_artifact_paths.py", - "tests/projects/guide_compilation/proposals/test_selected_checks.py", "tests/projects/guide_compilation/proposals/test_authority_contract.py", "tests/projects/guide_compilation/proposals/test_contracts.py", "tests/projects/guide_compilation/proposals/test_audit_schema.py", @@ -204,13 +210,11 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/projects/public_activation/test_failures.py", "tests/projects/public_activation/test_workflow.py", "tests/projects/post_policy/test_authority.py", - "tests/projects/post_policy/test_compiler.py", "tests/projects/post_policy/test_public_api.py", "tests/projects/post_policy/test_public_recovery.py", "tests/projects/post_policy/test_delivery_worker.py", "tests/projects/post_policy/test_concurrency.py", "tests/projects/post_policy/test_correction.py", - "tests/projects/post_policy/test_direct_sql.py", "tests/projects/post_policy/test_guards.py", "tests/projects/post_policy/test_inventory.py", "tests/projects/post_policy/test_schema.py", @@ -253,7 +257,6 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/projects/test_locked_policy_custody.py", "tests/projects/test_locked_policy_concurrency.py", "tests/projects/test_locked_policy_contract.py", - "tests/projects/test_activation_readiness.py", "tests/projects/test_policy_read_composition.py", "tests/projects/test_active_guide_read_composition.py", "tests/projects/test_diagnostic_read_composition.py", @@ -287,9 +290,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: == modules_by_lane["task_lifecycle_b"] == modules_by_lane["task_lifecycle_c"] == { - "tests/checkers/execution/test_completion_evidence.py", "tests/checkers/execution/test_completion_contract.py", - "tests/tasks/post_submit_routing/test_failure_completion.py", "tests/tasks/post_submit_routing/test_remediation.py", "tests/tasks/post_submit_routing/test_remediation_storage.py", "tests/tasks/post_submit_routing/test_remediation_currentness.py", @@ -355,29 +356,8 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/tasks/test_payment_policy_migration.py", "tests/tasks/test_contribution_claim_races.py", "tests/tasks/test_submission_lineage.py", - "tests/checkers/post_submit/test_catalogue.py", - "tests/checkers/post_submit/test_compiled_policy.py", - "tests/checkers/post_submit/test_configuration.py", - "tests/checkers/post_submit/test_conformance.py", - "tests/checkers/post_submit/test_input_bounds.py", "tests/checkers/post_submit/test_public_boundary.py", - "tests/checkers/post_submit/test_request.py", "tests/checkers/post_submit/test_requirement_dispositions.py", - "tests/checkers/post_submit/test_result_contract.py", - "tests/checkers/test_effective_intake_rules.py", - "tests/test_blocked_pre_submit_feedback_postgresql.py", - "tests/test_checker_catalogue.py", - "tests/test_checkers.py", - "tests/test_default_pre_submit_execution.py", - "tests/test_approved_guide_intake.py", - "tests/test_pre_submit_attempt_recovery.py", - "tests/test_pre_submit_attempt_contracts.py", - "tests/test_pre_submit_attempt_authority_integration.py", - "tests/test_pre_submit_attempt_lock_order.py", - "tests/test_pre_submit_role_issue_lock_order.py", - "tests/test_pre_submit_related_lock_order.py", - "tests/test_pre_submit_attempt_schema.py", - "tests/test_effective_pre_submit_execution.py", "tests/test_project_guide_compilation_contracts.py", "tests/test_review_lease_persistence.py", "tests/test_review_queue_persistence.py", diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py deleted file mode 100644 index 5acddc857..000000000 --- a/backend/tests/test_default_pre_submit_execution.py +++ /dev/null @@ -1,1561 +0,0 @@ -"""Proof for plan-bound hidden Workstream-default pre-submit execution.""" - -from __future__ import annotations - -from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test - -from project_create_fixtures import guide_snapshot_columns - -import asyncio -from io import BytesIO -from dataclasses import replace -from pathlib import Path -import threading -from types import SimpleNamespace -from typing import Any, cast -from unittest.mock import AsyncMock -import zipfile -from uuid import UUID -from app.core.identifiers import new_record_id - -import pytest -from sqlalchemy import func, select, text -from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from app.core.hashing import canonical_json_hash -from app.core.config import Settings -from app.modules.artifacts.preparation import ( - HARD_MAXIMUM_ARTIFACT_BYTES, - ArtifactPreparationLimits, - ArtifactPreparationService, - ArtifactPreparationDeadlineError, - ArtifactScratchManager, -) -from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError -from app.modules.artifacts.models import ( - ArtifactContent, - ArtifactOperationReceipt, - ArtifactPutAttempt, - ArtifactReplica, - ArtifactVerificationJob, - ArtifactVerificationReceipt, - SubmissionBundleDurableIntent, -) -from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidenceConflict, _validate_execution -from app.modules.artifacts.service import ( - ArtifactAdmissionRelationshipError, - ArtifactStorageNamespaceSpec, -) -from app.modules.artifacts.submission_admission import ( - SubmissionBundleAdmissionPublisher, - SubmissionBundleDurablePutRequest, -) -from app.modules.artifacts.operator import ArtifactOperatorService -from app.modules.artifacts.metrics import artifact_admission_metrics -from app.modules.artifacts.schemas import ArtifactOperatorAuthorizationEvidence -from app.modules.artifacts.submission_authorization import ( - DenySubmissionBundlePreparedAuthorization, -) -from app.modules.artifacts.submission_archive import ( - SubmissionArchiveInspector, - SubmissionArchiveLimits, -) -from app.modules.artifacts.submission_manifest import ( - build_submission_manifest, - evaluate_submission_change, -) -from app.modules.artifacts.submission_materialization import ( - DenyPreSubmitMaterializationAuthorization, - PreparedBundleMaterializationRequest, - PreparedBundlePreSubmitEvidenceService, - PreparedBundleMaterializationService, -) -from app.modules.authorization.prepared import PreparedAuthorizationHandle -from app.modules.authorization.catalogue import ACTION_BY_ID -from app.modules.checkers.catalogue import ( - PreSubmissionCheckerPhase, - build_pre_submission_checker_catalogue, -) -from app.modules.checkers.compiler import compile_effective_project_submission_artifact_policy -from app.modules.checkers.effective_plan import ( - EffectivePreSubmissionPlanLineage, - compile_effective_pre_submission_execution_plan, -) -from app.modules.checkers.pre_submit_execution import ( - PreSubmissionResultStatus, - SubmissionPacketView, -) -from app.modules.checkers.api import PreSubmissionInfrastructureUnavailableError -from tests.artifact_store_helpers import artifact_admission_limit_settings -from tests.pre_submit_test_helpers import ( - approved_pre_submit_fixture, - durable_put_service, - assert_pre_submit_evidence_immutable, - assert_admission_replay_state, - materialize_member_fixture, - execute_evidence_workflow, - checker_execution as _CheckerExecution, - evidence_workflow, - submission_preparation_request, -) -from tests.submission_preparation_auth_helpers import ( - install_submitter_grant, - prepared_submitter_authority, - table_counts, -) - - -async def _bytes(value: bytes): - yield value - - -class _AllowOperatorAuthority: - async def authorize(self, *, facts, **_values) -> ArtifactOperatorAuthorizationEvidence: - return ArtifactOperatorAuthorizationEvidence( - action_id=facts.action_id, - permission_id=ACTION_BY_ID[facts.action_id].permission_id.value, - decision_id=new_record_id(), - ) - - -@pytest.mark.asyncio -async def test_evidence_workflow_requires_transaction_free_session() -> None: - materialization = SimpleNamespace(materialize_prepared_bundle=lambda _request: None) - workflow = PreparedBundlePreSubmitEvidenceService( - session=cast(Any, SimpleNamespace(in_transaction=lambda: True)), - materialization=cast(Any, materialization), - preparation_authorization=cast(Any, SimpleNamespace()), - task_contexts=cast(Any, SimpleNamespace()), - project_contexts=cast(Any, SimpleNamespace()), - ) - - with pytest.raises(RuntimeError, match="requires a transaction-free session"): - await workflow.execute_reserved( - cast(Any, object()), object(), - preparation_request=cast(Any, object()), - ) - - -def _archive(path: str = "task.toml", *, extra_path: str | None = None, evidence_path="evidence/results") -> bytes: - output = BytesIO() - with zipfile.ZipFile(output, "w") as archive: - - def write(name: str, value: bytes) -> None: - entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) - archive.writestr(entry, value) - - write(path, b"[task]\nname='proof'\n") - if extra_path is not None: - write(extra_path, b"blocked\n") - if path != evidence_path: - write(evidence_path, b"verified\n") - return output.getvalue() - - -def _effective_policy() -> dict[str, object]: - defaults = { - "required_packet_fields": ["summary", "worker_attestation", "artifact_hash_manifest"], - "forbidden_artifacts": [{"pattern": ".env"}, {"pattern": ".git/**"}], - "attestation_terms": ["rights_confirmed"], - } - return { - "workstream_default_policy": defaults, - "project_policy": {}, - "required_packet_fields": defaults["required_packet_fields"], - "required_artifacts": [{"key": "task.toml", "path": "task.toml", "required": True}], - "required_evidence": [{"key": "results", "required": True}], - "forbidden_artifacts": defaults["forbidden_artifacts"], - "attestation_terms": defaults["attestation_terms"], - "manifest_required": True, - "artifact_hash_required": True, - "artifact_hash_algorithm": "sha256", - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": 1_000_000, - "maximum_package_size_bytes": 5_000_000, - "packaging": {"package_required": True, "allowed_package_formats": ["zip"]}, - } - - -def _plan(catalogue, *, policy=None): - policy = _effective_policy() if policy is None else policy - policy_hash = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, policy_hash) - snapshot_id = new_record_id() - lineage = EffectivePreSubmissionPlanLineage( - project_id=new_record_id(), - guide_id=new_record_id(), - guide_version="v0.1", - source_snapshot_id=snapshot_id, - source_snapshot_hash=guide_snapshot_columns(str(snapshot_id))["bundle_hash"], - effective_policy_id=new_record_id(), - effective_policy_hash=policy_hash, - pre_submit_policy_id=new_record_id(), - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, - ) - return compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=policy, - compiled_bundle=compiled.compiled_bundle, - catalogue=catalogue, - ) - - -def _rehash_plan(plan, *, entries=None, catalogue_manifest_sha256=None): - changed = replace( - plan, - entries=plan.entries if entries is None else tuple(entries), - catalogue_manifest_sha256=( - plan.catalogue_manifest_sha256 - if catalogue_manifest_sha256 is None - else catalogue_manifest_sha256 - ), - ) - return replace(changed, plan_sha256=canonical_json_hash(changed.as_dict())) - - -def _limits() -> ArtifactPreparationLimits: - return ArtifactPreparationLimits( - aggregate_reserved_bytes=2 * HARD_MAXIMUM_ARTIFACT_BYTES, - maximum_files=2, - maximum_concurrency=2, - minimum_free_bytes=0, - reservation_ttl_seconds=30, - total_deadline_seconds=10, - cleanup_margin_seconds=5, - stream_buffer_bytes=1024, - maximum_source_bytes=1024 * 1024, - maximum_workspace_entries=2_000, - ) - - -class _AllowAuthority: - def __init__(self) -> None: - self.preparation_facts = None - self.facts = None - self.action_id = None - self.service_identity = None - - async def prepare(self, *, facts, idempotency_key): - del idempotency_key - self.preparation_facts = facts - return _handle() - - async def consume(self, **values): - self.facts = values["facts"] - - -class _TestPreparedAuthorizationHandle: - """Typed sentinel accepted only by the bounded authorization protocol double.""" - - -def _handle() -> PreparedAuthorizationHandle: - return cast(PreparedAuthorizationHandle, _TestPreparedAuthorizationHandle()) - - -async def _request( - tmp_path: Path, - *, - path: str = "task.toml", - extra_path: str | None = None, - catalogue=None, - plan=None, - evidence_path="evidence/results", -): - selected_catalogue = catalogue or build_pre_submission_checker_catalogue() - plan = _plan(selected_catalogue) if plan is None else plan - data = _archive(path, extra_path=extra_path, evidence_path=evidence_path) - inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) - manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) - preparation = ArtifactPreparationService(manager) - prepared = await preparation.prepare(_bytes(data), media_type="application/zip") - inspection = await prepared.inspect(inspector) - manifest = build_submission_manifest(inspection) - change = evaluate_submission_change( - commitment=prepared.commitment, - manifest=manifest, - predecessor=None, - predecessor_exists=False, - ) - request = PreparedBundleMaterializationRequest( - prepared_authorization=_handle(), - task_id=new_record_id(), - assignment_id=new_record_id(), - submission_artifact_policy_id=plan.lineage.effective_policy_id, - checker_policy_id=plan.lineage.pre_submit_policy_id, - predecessor_submission_version=None, - prepared_artifact=prepared, - effective_plan=plan, - inspection=inspection, - manifest=manifest, - change_gate=change, - packet=SubmissionPacketView( - summary="Completed exact project work.", - contributor_attestation=( - "I confirm no confidential client data, credentials, or copied source " - "material is included in this submission; rights_confirmed." - ), - ), - ) - return request, inspector, manager, preparation, selected_catalogue - - -@pytest.mark.asyncio -async def test_authority_denial_precedes_workspace_and_checker_access(tmp_path: Path) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=DenyPreSubmitMaterializationAuthorization(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - with pytest.raises(ArtifactAuthorityDeniedError): - await materialize_member_fixture(service, request) - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_authority_preparation_denies_before_zip_inspection( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - catalogue = build_pre_submission_checker_catalogue() - plan = _plan(catalogue) - manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) - preparation = ArtifactPreparationService(manager) - prepared = await preparation.prepare(_bytes(_archive()), media_type="application/zip") - inspection_calls = 0 - - async def forbidden_inspection(*_args, **_kwargs): - nonlocal inspection_calls - inspection_calls += 1 - raise AssertionError("ZIP inspection preceded authority preparation") - - monkeypatch.setattr(type(prepared), "inspect", forbidden_inspection) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=DenyPreSubmitMaterializationAuthorization(), - preparation=preparation, - checker_execution=_CheckerExecution( - SubmissionArchiveInspector(SubmissionArchiveLimits()), catalogue - ), - storage_scheme="s3", - ) - - with pytest.raises(ArtifactAuthorityDeniedError): - await service.prepare_authorization( - task_id=new_record_id(), - assignment_id=new_record_id(), - submission_artifact_policy_id=plan.lineage.effective_policy_id, - checker_policy_id=plan.lineage.pre_submit_policy_id, - prepared_artifact=prepared, - effective_plan=plan, - idempotency_key=new_record_id(), - ) - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - assert inspection_calls == 0 - await prepared.close() - manager.close() - - -@pytest.mark.asyncio -async def test_manifest_drift_denies_before_authority_and_workspace(tmp_path: Path) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - first = next(entry for entry in request.inspection.entries if entry.sha256 is not None) - forged_entry = replace(first, sha256="sha256:" + "f" * 64) - forged_inspection = replace( - request.inspection, - entries=tuple( - forged_entry if entry is first else entry for entry in request.inspection.entries - ), - ) - forged_manifest = build_submission_manifest(forged_inspection) - forged_change = evaluate_submission_change( - commitment=request.prepared_artifact.commitment, - manifest=forged_manifest, - predecessor=None, - predecessor_exists=False, - ) - authority = _AllowAuthority() - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=authority, - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - with pytest.raises( - PreSubmissionInfrastructureUnavailableError, - match="materialization_context_invalid", - ): - await materialize_member_fixture(service, - replace(request, manifest=forged_manifest, change_gate=forged_change) - ) - - assert authority.facts is None - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_two_stage_authority_uses_one_handle_and_exact_final_facts( - tmp_path: Path, -) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - authority = _AllowAuthority() - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=authority, - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - handle = await service.prepare_authorization( - task_id=request.task_id, - assignment_id=request.assignment_id, - submission_artifact_policy_id=request.submission_artifact_policy_id, - checker_policy_id=request.checker_policy_id, - prepared_artifact=request.prepared_artifact, - effective_plan=request.effective_plan, - idempotency_key=new_record_id(), - ) - - result = await materialize_member_fixture(service, - replace(request, prepared_authorization=handle) - ) - - assert result.eligible is True - assert authority.preparation_facts is not None - assert authority.facts is not None - assert authority.facts.preparation == authority.preparation_facts - assert authority.facts.semantic_manifest_sha256 == request.manifest.sha256 - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_effective_evidence_workflow_persists_once_and_replays_exactly( - tmp_path: Path, - isolated_database_env: str, -) -> None: - engine = create_async_engine(isolated_database_env) - session_factory = async_sessionmaker(engine, expire_on_commit=False) - namespace = ArtifactStorageNamespaceSpec( - backend="local", adapter="local", provider_profile="test", - namespace_descriptor={"test": "submission-bundle"}, - namespace_fingerprint=canonical_json_hash({"test": "submission-bundle"}), - ) - plan, policy_params = await approved_pre_submit_fixture(session_factory, namespace, guide_version="v0.1") - evidence_path = policy_params.pop("evidence_path") - request, inspector, manager, preparation, catalogue = await _request(tmp_path, plan=plan, evidence_path=evidence_path) - request = replace(request, packet=replace(request.packet, contributor_attestation=( - request.packet.contributor_attestation + " " + " ".join(policy_params.pop("attestation_terms")) - ))) - actor_id = new_record_id() - identity_link_id = new_record_id() - lineage = request.effective_plan.lineage - blocked_prepared = replay_prepared = drift_prepared = denied_prepared = None - original_prepared_closed = False - tables = ( - "artifact_contents", - "artifact_replicas", - "artifact_put_attempts", - "submission_bundle_admissions", - "submissions", - "checker_runs", - "review_queue_entries", - "pre_submit_evidence_sets", - "pre_submit_evidence_results", - ) - try: - async with engine.begin() as connection: - params = { - "actor": str(actor_id), "subject": str(actor_id), - "link": str(identity_link_id), - "project": str(lineage.project_id), - "guide": str(lineage.guide_id), - "snapshot": str(lineage.source_snapshot_id), - "snapshot_hash": lineage.source_snapshot_hash, - **policy_params, - "effective_policy": str(lineage.effective_policy_id), - "effective_hash": lineage.effective_policy_hash, - "checker_policy": str(lineage.pre_submit_policy_id), - "checker_hash": lineage.pre_submit_policy_bundle_hash, - "task": str(request.task_id), - "assignment": str(request.assignment_id), - } - await connection.execute( - text( - "insert into actor_profiles " - "(id,actor_kind,status,provisioning_method,created_by) values " - "(:actor,'human','active','automatic_first_access','test')" - ), - params, - ) - await connection.execute( - text( - "insert into actor_identity_links " - "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by," - "last_verified_at) values " - "(:link,:actor,'flow-test',:subject,'human','active','test',now())" - ), - params, - ) - await seed_started_task_for_artifact_test(connection, params) - await install_submitter_grant(connection, params) - before = await table_counts(connection, tables) - session_factory = async_sessionmaker(engine, expire_on_commit=False) - async with session_factory() as session: - preparation_authority = cast(Any, SimpleNamespace(lock_actor=AsyncMock(), revalidate=AsyncMock())) - workflow = evidence_workflow( - session=session, - preparation=preparation, - inspector=inspector, - catalogue=catalogue, - materialization_authorization=_AllowAuthority(), - preparation_authorization=preparation_authority, - ) - preparation_request = submission_preparation_request( - request, - actor_profile_id=actor_id, - identity_link_id=identity_link_id, - ) - async def fresh_checked_bundle(): - prepared = await preparation.prepare( - _bytes(_archive(evidence_path=evidence_path)), - media_type="application/zip", - ) - inspection = await prepared.inspect(inspector) - manifest = build_submission_manifest(inspection) - fresh_request = replace( - request, - prepared_artifact=prepared, - inspection=inspection, - manifest=manifest, - change_gate=evaluate_submission_change( - commitment=prepared.commitment, - manifest=manifest, - predecessor=None, - predecessor_exists=False, - ), - ) - result = await execute_evidence_workflow(workflow, - fresh_request, - preparation_request=replace(preparation_request, idempotency_key=new_record_id()), - ) - assert result.pass_capability is not None - return prepared, result - - first = await execute_evidence_workflow(workflow, - request, - preparation_request=preparation_request, - ) - replay = await execute_evidence_workflow(workflow, - request, - preparation_request=preparation_request, - ) - assert first.pass_capability is not None, first.failure_audit - admission_settings = Settings(_env_file=None, - **artifact_admission_limit_settings(1024 * 1024), - environment="test", - artifact_store_backend="local", - artifact_local_root=tmp_path / "durable", - artifact_scratch_root=tmp_path / "scratch", - artifact_scratch_minimum_free_bytes=0, - ) - provider = SimpleNamespace( - execute_committed_put=AsyncMock(), - resume_committed_put=AsyncMock(), - ) - async with session.begin(): - final_authority = await prepared_submitter_authority( - session, preparation_request, actor_id, identity_link_id, lineage.project_id, - ) - durable_service = durable_put_service( - session, admission_settings, namespace, provider, final_authority, - ) - ( - retained, - selected_evidence_id, - first_admission, - ) = await durable_service.admit_in_transaction( - SubmissionBundleDurablePutRequest( - prepared_authorization=None, - prepared_artifact=request.prepared_artifact, - pass_capability=first.pass_capability, - ) - ) - provider.execute_committed_put.assert_not_awaited() - provider.resume_committed_put.assert_not_awaited() - intent = await session.scalar( - select(SubmissionBundleDurableIntent).where( - SubmissionBundleDurableIntent.put_attempt_id == str(first_admission.attempt_id) - ) - ) - assert intent is not None - replay_intent_id = UUID(intent.id) - await session.rollback() - assert retained is request.prepared_artifact - await retained.close() - original_prepared_closed = True - - replay_prepared, fresh = await fresh_checked_bundle() - async with session.begin(): - ( - replay_retained, - replay_evidence_id, - replay_admission, - ) = await durable_service.admit_in_transaction( - SubmissionBundleDurablePutRequest( - prepared_authorization=None, - prepared_artifact=replay_prepared, - pass_capability=fresh.pass_capability, - replay_durable_intent_id=replay_intent_id, - ) - ) - assert replay_admission.replayed is True - assert replay_admission.attempt_id == first_admission.attempt_id - assert replay_evidence_id == fresh.evidence.evidence_set_id - await replay_retained.close() - intent_count = int( - await session.scalar( - select(func.count()).select_from(SubmissionBundleDurableIntent) - ) - or 0 - ) - assert intent_count == 1 - await session.rollback() - - drift_prepared, drift = await fresh_checked_bundle() - await session.execute( - text("update task_assignments set status='inactive' where id=:assignment"), - {"assignment": str(request.assignment_id)}, - ) - await session.commit() - with pytest.raises( - ArtifactAdmissionRelationshipError, - match="passing evidence is unavailable|locked_context", - ): - async with session.begin(): - await durable_service.admit_in_transaction( - SubmissionBundleDurablePutRequest( - prepared_authorization=None, - prepared_artifact=drift_prepared, - pass_capability=drift.pass_capability, - ) - ) - await session.execute( - text("update task_assignments set status='active' where id=:assignment"), - {"assignment": str(request.assignment_id)}, - ) - await session.commit() - - denied_prepared, denied = await fresh_checked_bundle() - denied_service = durable_put_service( - session, admission_settings, namespace, provider, - DenySubmissionBundlePreparedAuthorization(), - ) - with pytest.raises(ArtifactAuthorityDeniedError): - async with session.begin(): - await denied_service.admit_in_transaction( - SubmissionBundleDurablePutRequest( - prepared_authorization=None, - prepared_artifact=denied_prepared, - pass_capability=denied.pass_capability, - ) - ) - async with session.begin(): - attempt = await session.get( - ArtifactPutAttempt, - str(first_admission.attempt_id), - with_for_update=True, - ) - assert attempt is not None - content = ArtifactContent( - id=str(new_record_id()), - sha256=attempt.sha256, - byte_count=attempt.byte_count, - media_type=attempt.media_type, - normalized_display_name=None, - ) - replica = ArtifactReplica( - id=str(new_record_id()), - content_id=content.id, - storage_namespace_id=attempt.storage_namespace_id, - namespace_fingerprint=attempt.namespace_fingerprint, - adapter="local", - provider_profile="test", - provider_object_ref=attempt.canonical_target, - verification_state="verified", - availability_state="available", - integrity_state="valid", - ) - session.add_all((content, replica)) - await session.flush() - put_receipt = ArtifactOperationReceipt( - id=str(new_record_id()), - put_attempt_id=attempt.id, - guide_source_item_id=None, - checker_run_id=None, - logical_role=None, - replica_id=replica.id, - operation="put", - idempotency_key=attempt.operation_identity, - request_digest=attempt.request_digest, - provider_object_ref=attempt.canonical_target, - replayed=False, - outcome="stored_pending_verification", - attempt_number=1, - correlation_id=attempt.operation_identity, - details=[], - ) - job = ArtifactVerificationJob( - id=str(new_record_id()), - originating_put_attempt_id=attempt.id, - replica_id=replica.id, - status="verified", - maximum_attempts=5, - execution_generation=1, - ) - session.add_all((put_receipt, job)) - attempt.status = "object_confirmed" - attempt.replica_id = replica.id - attempt.receipt_id = put_receipt.id - verification_receipt = ArtifactVerificationReceipt( - id=str(new_record_id()), - verification_job_id=job.id, - execution_generation=1, - outcome="verified", - observed_sha256=attempt.sha256, - observed_byte_count=attempt.byte_count, - ) - session.add(verification_receipt) - await session.flush() - job_id = job.id - verification_receipt_id = verification_receipt.id - attempt_byte_count = attempt.byte_count - - async def publish_ready() -> str: - async with session_factory() as publisher_session: - async with publisher_session.begin(): - ready = await SubmissionBundleAdmissionPublisher( - publisher_session - ).publish_verified( - verification_job_id=job_id, - verification_receipt_id=verification_receipt_id, - ) - assert ready is not None and ready.status == "ready" - return ready.id - - published_ids = await asyncio.gather(publish_ready(), publish_ready()) - assert published_ids[0] == published_ids[1] - async with session.begin(): - usage = await ArtifactOperatorService( - session, - _AllowOperatorAuthority(), - admission_settings, - artifact_admission_metrics, - ).admission_usage( - authorization_context=cast(Any, object()), - project_id=lineage.project_id, - task_id=request.task_id, - cursor=None, - limit=10, - ) - assert len(usage.items) == 3 - assert all(item["unbound_ready_count"] == 1 for item in usage.items) - assert all( - item["unbound_ready_bytes"] == attempt_byte_count for item in usage.items - ) - assert all(item["stale_count"] == 0 for item in usage.items) - durable_counts = { - table: int(await session.scalar(text(f"select count(*) from {table}")) or 0) - for table in ( - "artifact_put_attempts", - "submission_bundle_durable_intents", - "artifact_admission_charges", - ) - } - guide_continuation_matches = int( - await session.scalar( - text( - "select count(*) from artifact_put_attempts attempt " - "join guide_source_snapshot_items item " - "on item.id=attempt.guide_source_item_id " - "where attempt.producer_request_type='submission_bundle'" - ) - ) - or 0 - ) - await session.rollback() - assert durable_counts == { - "artifact_put_attempts": before["artifact_put_attempts"] + 1, - "submission_bundle_durable_intents": 1, - "artifact_admission_charges": 4, - } - assert guide_continuation_matches == 0 - provider.execute_committed_put.assert_not_awaited() - provider.resume_committed_put.assert_not_awaited() - assert selected_evidence_id == first.evidence.evidence_set_id - await assert_admission_replay_state(session, selected_evidence_id, - first_admission.attempt_id, published_ids[0], "ready") - await session.execute( - text( - "update submission_bundle_admissions set status='stale', " - "stale_at=now(), stale_reason='predecessor_advanced' where id=:id" - ), - {"id": published_ids[0]}, - ) - await session.commit() - await assert_admission_replay_state(session, selected_evidence_id, - first_admission.attempt_id, published_ids[0], "stale") - async with session.begin(): - stale_usage = await ArtifactOperatorService( - session, - _AllowOperatorAuthority(), - admission_settings, - artifact_admission_metrics, - ).admission_usage( - authorization_context=cast(Any, object()), - project_id=lineage.project_id, - task_id=request.task_id, - cursor=None, - limit=10, - ) - assert all(item["unbound_ready_count"] == 0 for item in stale_usage.items) - assert all(item["stale_count"] == 1 for item in stale_usage.items) - assert all(item["stale_bytes"] == attempt_byte_count for item in stale_usage.items) - with pytest.raises(DBAPIError): - await session.execute( - text( - "update submission_bundle_admissions set archive_sha256=:digest " - "where id=:id" - ), - {"id": published_ids[0], "digest": "sha256:" + "9" * 64}, - ) - await session.commit() - await session.rollback() - with pytest.raises(DBAPIError): - await session.execute( - text("delete from submission_bundle_admissions where id=:id"), - {"id": published_ids[0]}, - ) - await session.commit() - await session.rollback() - blocked_prepared = await preparation.prepare( - _bytes(_archive("task.toml", evidence_path=evidence_path)), media_type="application/zip" - ) - blocked_inspection = await blocked_prepared.inspect(inspector) - blocked_manifest = build_submission_manifest(blocked_inspection) - blocked_request = replace( - request, - prepared_artifact=blocked_prepared, - inspection=blocked_inspection, - manifest=blocked_manifest, - change_gate=evaluate_submission_change( - commitment=blocked_prepared.commitment, - manifest=blocked_manifest, - predecessor=None, - predecessor_exists=False, - ), - packet=SubmissionPacketView( - summary="Completed exact project work.", - contributor_attestation="", - ), - ) - blocked = await execute_evidence_workflow(workflow, - blocked_request, - preparation_request=replace( - preparation_request, idempotency_key=new_record_id(), - contributor_attestation=blocked_request.packet.contributor_attestation, - summary=blocked_request.packet.summary, - ), - ) - async with engine.begin() as connection: - evidence_count = int( - await connection.scalar(text("select count(*) from pre_submit_evidence_sets")) or 0 - ) - result_count = int( - await connection.scalar(text("select count(*) from pre_submit_evidence_results")) - or 0 - ) - after = { - table: int(await connection.scalar(text(f"select count(*) from {table}")) or 0) - for table in tables - } - await assert_pre_submit_evidence_immutable(connection) - finally: - for prepared in ( - blocked_prepared, - replay_prepared, - drift_prepared, - denied_prepared, - ): - if prepared is not None: - await prepared.close() - if not original_prepared_closed: - await request.prepared_artifact.close() - try: - manager.close() - finally: - await engine.dispose() - - assert first.evidence.replayed is False - assert replay.evidence.replayed is True - assert replay.evidence.evidence_set_id == first.evidence.evidence_set_id - assert first.pass_capability is not None - assert replay.pass_capability is None - assert first.failure_audit is None - assert blocked.pass_capability is None - assert blocked.evidence.replayed is False - assert blocked.failure_audit is not None - assert blocked.failure_audit["event_type"] == "pre_submission_check_failed" - assert blocked.failure_audit["failed_count"] >= 1 - assert "task.toml" not in repr(blocked.failure_audit) - assert (evidence_count, result_count) == (5, 5 * len(request.effective_plan.entries)) - assert after == { - **before, - "artifact_contents": before["artifact_contents"] + 1, - "artifact_replicas": before["artifact_replicas"] + 1, - "artifact_put_attempts": before["artifact_put_attempts"] + 1, - "submission_bundle_admissions": before["submission_bundle_admissions"] + 1, - "pre_submit_evidence_sets": evidence_count, "pre_submit_evidence_results": result_count, # noqa: E501 - } - - -@pytest.mark.asyncio -async def test_materializer_rejects_policy_lineage_mismatch_before_authority( - tmp_path: Path, -) -> None: - """Reject caller-selected policy identity before consuming authority.""" - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - authority = _AllowAuthority() - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=authority, - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - with pytest.raises( - PreSubmissionInfrastructureUnavailableError, - match="pre_submission_materialization_context_invalid", - ): - await materialize_member_fixture(service, - replace(request, submission_artifact_policy_id=new_record_id()) - ) - - assert authority.facts is None - await request.prepared_artifact.close() - await preparation.release_prepared_artifact(request.prepared_artifact._binding) - manager.close() - - -@pytest.mark.asyncio -async def test_effective_executor_uses_plan_order_and_dispatches_project_rules( - tmp_path: Path, -) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - authority = _AllowAuthority() - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=authority, - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - result = await materialize_member_fixture(service, request) - - expected = [ - entry.definition_id - for entry in request.effective_plan.entries - if entry.phase in set(PreSubmissionCheckerPhase) - ] - assert [entry.definition_id for entry in result.entries] == expected - assert any(entry.definition_id.startswith("policy.") for entry in result.entries) - assert all( - entry.checker_execution_status == PreSubmissionResultStatus.PASSED.value - for entry in result.entries - ) - assert result.eligible is True - assert authority.facts is not None - assert authority.facts.task_id == request.task_id - assert authority.facts.assignment_id == request.assignment_id - assert authority.facts.project_id == request.effective_plan.lineage.project_id - assert authority.facts.guide_version == "v0.1" - assert authority.facts.submission_artifact_policy_id == request.submission_artifact_policy_id - assert authority.facts.checker_policy_id == request.checker_policy_id - assert authority.facts.prepared_generation_id == request.prepared_artifact.generation_id - assert authority.facts.plan_sha256 == request.effective_plan.plan_sha256 - assert ( - authority.facts.catalogue_manifest_sha256 - == request.effective_plan.catalogue_manifest_sha256 - ) - assert authority.facts.archive_sha256 == request.prepared_artifact.commitment.sha256 - assert authority.facts.archive_byte_count == request.prepared_artifact.commitment.byte_count - assert authority.facts.semantic_manifest_sha256 == request.manifest.sha256 - assert authority.facts.storage_scheme == "s3" - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_blocking_default_stops_later_dependency_without_review_decision( - tmp_path: Path, -) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path, path=".env") - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - result = await materialize_member_fixture(service, request) - by_id = {entry.definition_id: entry for entry in result.entries} - - assert ( - by_id["artifact.sensitive_paths.high_confidence"].checker_execution_status - == PreSubmissionResultStatus.FAILED.value - ) - assert ( - by_id["artifact.quality.placeholder_signal"].checker_execution_status - == PreSubmissionResultStatus.DEPENDENCY_NOT_RUN.value - ) - assert result.eligible is False - assert all( - value not in {"accept", "needs_revision", "reject"} - for entry in result.entries - for value in (entry.checker_execution_status, entry.message_code, entry.failure_code) - if value is not None - ) - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_disabled_advisory_is_explicit_and_not_skipped_success(tmp_path: Path) -> None: - catalogue = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) - ) - request, inspector, manager, preparation, _ = await _request(tmp_path, catalogue=catalogue) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - result = await materialize_member_fixture(service, request) - advisory = next( - entry - for entry in result.entries - if entry.definition_id == "artifact.quality.placeholder_signal" - ) - - assert advisory.checker_execution_status == PreSubmissionResultStatus.ADVISORY_DISABLED.value - assert result.eligible is True - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_quality_warning_emits_only_a_bounded_category_count(tmp_path: Path) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - request = replace( - request, - packet=replace(request.packet, summary="Completed work; TODO placeholder removed."), - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - result = await materialize_member_fixture(service, request) - warning = next( - entry - for entry in result.entries - if entry.definition_id == "artifact.quality.placeholder_signal" - ) - - assert warning.checker_execution_status == PreSubmissionResultStatus.WARNING.value - assert warning.metadata == (("matched_category_count", 2),) - assert result.eligible is True - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_forged_plan_identity_fails_closed_and_cleans_workspace(tmp_path: Path) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - forged = replace(request.effective_plan, plan_sha256="sha256:" + "0" * 64) - request = replace(request, effective_plan=forged) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - with pytest.raises(PreSubmissionInfrastructureUnavailableError, match="plan_identity"): - await materialize_member_fixture(service, request) - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("invalid_state", "expected_message"), - ( - ("stale_entry", "pre_submission_plan_entry_stale"), - ("duplicate", "pre_submission_duplicate_result"), - ("unknown", "pre_submission_dispatch_capability_unknown"), - ), -) -async def test_invalid_executor_state_fails_closed_and_cleans_workspace( - tmp_path: Path, - invalid_state: str, - expected_message: str, -) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - entries = list(request.effective_plan.entries) - target_index = next( - index - for index, entry in enumerate(entries) - if entry.definition_id == "submission.packet.required_fields" - ) - selected_catalogue = catalogue - if invalid_state == "stale_entry": - entries[target_index] = replace(entries[target_index], definition_version="stale") - elif invalid_state == "duplicate": - entries.insert(target_index + 1, entries[target_index]) - else: - unknown = replace(entries[target_index], dispatch_capability="unknown.capability") - entries[target_index] = unknown - original = catalogue.definition(unknown.definition_id) - - class _UnknownCatalogue: - manifest_sha256 = catalogue.manifest_sha256 - - def definition(self, definition_id): - definition = catalogue.definition(definition_id) - if definition_id != original.stable_id: - return definition - - class _UnknownDefinition: - dispatch_capability = "unknown.capability" - - def __getattr__(self, name): - return getattr(definition, name) - - return _UnknownDefinition() - - selected_catalogue = _UnknownCatalogue() - request = replace( - request, - effective_plan=_rehash_plan(request.effective_plan, entries=entries), - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, selected_catalogue), - storage_scheme="s3", - ) - - with pytest.raises(PreSubmissionInfrastructureUnavailableError, match=expected_message): - await materialize_member_fixture(service, request) - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_disabled_mandatory_executor_state_fails_closed(tmp_path: Path) -> None: - request, inspector, manager, preparation, _ = await _request(tmp_path) - catalogue = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) - ) - entries = [ - replace(entry, checker_definition_state="disabled") - if entry.definition_id == "artifact.outer_zip.valid" - else entry - for entry in request.effective_plan.entries - ] - request = replace( - request, - effective_plan=_rehash_plan( - request.effective_plan, - entries=entries, - catalogue_manifest_sha256=catalogue.manifest_sha256, - ), - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - with pytest.raises(PreSubmissionInfrastructureUnavailableError): - await materialize_member_fixture(service, request) - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_effective_execution_enforces_project_only_forbidden_rule(tmp_path: Path) -> None: - catalogue = build_pre_submission_checker_catalogue() - policy = _effective_policy() - project_rule = {"pattern": "project-only.blocked"} - policy["project_policy"] = {"forbidden_artifacts": [project_rule]} - policy["forbidden_artifacts"] = [*policy["forbidden_artifacts"], project_rule] - policy_hash = canonical_json_hash(policy) - compiled = compile_effective_project_submission_artifact_policy(policy, policy_hash) - lineage = replace( - _plan(catalogue).lineage, - effective_policy_hash=policy_hash, - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, - ) - plan = compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=policy, - compiled_bundle=compiled.compiled_bundle, - catalogue=catalogue, - ) - request, inspector, manager, preparation, _ = await _request( - tmp_path, - extra_path="project-only.blocked", - catalogue=catalogue, - ) - request = replace( - request, - submission_artifact_policy_id=plan.lineage.effective_policy_id, - checker_policy_id=plan.lineage.pre_submit_policy_id, - effective_plan=plan, - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - result = await materialize_member_fixture(service, request) - - assert result.eligible is False - project_result = next( - entry for entry in result.entries if entry.definition_id == "policy.artifact.forbid" - ) - assert project_result.checker_execution_status == PreSubmissionResultStatus.FAILED.value - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_effective_execution_enforces_server_owned_storage_scheme(tmp_path: Path) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="local", - ) - - result = await materialize_member_fixture(service, request) - - policy_result = next( - entry for entry in result.entries if entry.definition_id == "policy.storage_scheme.enforce" - ) - assert policy_result.checker_execution_status == PreSubmissionResultStatus.FAILED.value - assert policy_result.message_code == "storage_scheme_not_allowed" - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_canonical_result_validator_rejects_forged_definition(tmp_path: Path) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - result = await materialize_member_fixture(service, request) - first = result.entries[0] - forged = replace( - result, - checker_facts=replace( - result.checker_facts, - entries=( - replace( - first, - public_name="caller-selected", - ), - *result.entries[1:], - ), - ), - ) - - with pytest.raises( - PreSubmitEvidenceConflict, - match="pre_submission_result_context_invalid", - ): - _validate_execution(request.effective_plan, forged) - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_post_submit_registry_is_not_an_intake_execution_dependency( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - import app.modules.checkers.runner as post_submit_runner - - def forbidden(*_args, **_kwargs): - raise AssertionError("post-submit registry was called") - - monkeypatch.setattr(post_submit_runner, "default_checker_registry", forbidden) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - - result = await materialize_member_fixture(service, request) - - assert result.eligible is True - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_authorized_cancellation_cleans_before_propagating( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - entered = threading.Event() - release = threading.Event() - from app.modules.checkers.pre_submit_execution import EffectivePreSubmissionProcessor - - original = EffectivePreSubmissionProcessor.process_blocking - - def blocking_process(self, reader, workspace): - entered.set() - assert release.wait(timeout=5) - return original(self, reader, workspace) - - monkeypatch.setattr(EffectivePreSubmissionProcessor, "process_blocking", blocking_process) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - task = asyncio.create_task(materialize_member_fixture(service, request)) - assert await asyncio.to_thread(entered.wait, 5) - task.cancel() - release.set() - - with pytest.raises(asyncio.CancelledError): - await task - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_cancellation_during_member_projection_cleans_workspace( - tmp_path: Path, -) -> None: - request, _inspector, manager, preparation, catalogue = await _request(tmp_path) - entered = threading.Event() - release = threading.Event() - - class _BlockingProjectionInspector(SubmissionArchiveInspector): - def _project_file(self, *args, **kwargs): - entered.set() - assert release.wait(timeout=5) - return super()._project_file(*args, **kwargs) - - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution( - _BlockingProjectionInspector(SubmissionArchiveLimits()), catalogue - ), - storage_scheme="s3", - ) - task = asyncio.create_task(materialize_member_fixture(service, request)) - assert await asyncio.to_thread(entered.wait, 5) - task.cancel() - release.set() - - with pytest.raises(asyncio.CancelledError): - await task - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -async def test_timeout_during_checker_access_cleans_workspace( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - request, inspector, manager, preparation, catalogue = await _request(tmp_path) - entered = threading.Event() - release = threading.Event() - from app.modules.checkers.pre_submit_execution import EffectivePreSubmissionProcessor - - original = EffectivePreSubmissionProcessor.process_blocking - - def blocking_process(self, reader, workspace): - entered.set() - assert release.wait(timeout=5) - return original(self, reader, workspace) - - monkeypatch.setattr(EffectivePreSubmissionProcessor, "process_blocking", blocking_process) - preparation._active[request.prepared_artifact._binding].deadline = ( - asyncio.get_running_loop().time() + 0.01 - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), - storage_scheme="s3", - ) - task = asyncio.create_task(materialize_member_fixture(service, request)) - assert await asyncio.to_thread(entered.wait, 5) - await asyncio.sleep(0.02) - release.set() - - with pytest.raises(ArtifactPreparationDeadlineError): - await task - - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("terminal", ("cancel", "timeout")) -async def test_terminal_event_during_sealing_precedes_checker_access_and_cleans( - tmp_path: Path, - terminal: str, - monkeypatch: pytest.MonkeyPatch, -) -> None: - request, _inspector, manager, preparation, catalogue = await _request(tmp_path) - entered = threading.Event() - release = threading.Event() - - class _BlockingSealInspector(SubmissionArchiveInspector): - def _seal_projected_content(self, root_fd, entries): - entered.set() - assert release.wait(timeout=5) - return super()._seal_projected_content(root_fd, entries) - - from app.modules.checkers.pre_submit_execution import EffectivePreSubmissionProcessor - - checker_called = threading.Event() - original_execute = EffectivePreSubmissionProcessor._execute - - def observed_execute(self, tree): - checker_called.set() - return original_execute(self, tree) - - monkeypatch.setattr(EffectivePreSubmissionProcessor, "_execute", observed_execute) - - if terminal == "timeout": - preparation._active[request.prepared_artifact._binding].deadline = ( - asyncio.get_running_loop().time() + 0.01 - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), - preparation=preparation, - checker_execution=_CheckerExecution( - _BlockingSealInspector(SubmissionArchiveLimits()), catalogue - ), - storage_scheme="s3", - ) - task = asyncio.create_task(materialize_member_fixture(service, request)) - assert await asyncio.to_thread(entered.wait, 5) - if terminal == "cancel": - task.cancel() - await asyncio.sleep(0) - else: - await asyncio.sleep(0.02) - release.set() - - expected = asyncio.CancelledError if terminal == "cancel" else ArtifactPreparationDeadlineError - with pytest.raises(expected): - await task - - assert checker_called.is_set() is False - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("field", ["artifact_hash_manifest", "unknown_packet_field"]) -async def test_policy_packet_fields_use_inspected_manifest_and_reject_unknown(tmp_path, field): - catalogue = build_pre_submission_checker_catalogue() - policy = _effective_policy() - policy["required_packet_fields"] = [field] - policy["workstream_default_policy"]["required_packet_fields"] = [field] - request, inspector, manager, preparation, _ = await _request( - tmp_path, catalogue=catalogue, plan=_plan(catalogue, policy=policy), - ) - service = PreparedBundleMaterializationService( - session=SimpleNamespace(), - authorization=_AllowAuthority(), preparation=preparation, - checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", - ) - try: - if field == "unknown_packet_field": - with pytest.raises(PreSubmissionInfrastructureUnavailableError, match="pre_submission_policy_field_unmappable"): - await materialize_member_fixture(service, request) - else: - outcome = await materialize_member_fixture(service, request) - assert outcome.eligible - assert outcome.custody.semantic_manifest_sha256 == request.manifest.sha256 - finally: - await request.prepared_artifact.close() - manager.close() diff --git a/backend/tests/test_effective_pre_submit_execution.py b/backend/tests/test_effective_pre_submit_execution.py deleted file mode 100644 index 00c3f231e..000000000 --- a/backend/tests/test_effective_pre_submit_execution.py +++ /dev/null @@ -1,506 +0,0 @@ -"""Focused proof for canonical effective pre-submit execution and evidence custody.""" - -from dataclasses import replace -from types import SimpleNamespace -from uuid import UUID -from app.core.identifiers import new_record_id - -from submission_context_fixtures import submission_context_facts - -import pytest - -from app.core.hashing import canonical_json_hash - -from app.modules.artifacts.pre_submit_evidence import ( - PreSubmitExecutionCustody, - PreSubmitExecutionResult, - PreSubmitEvidenceConflict, - PreSubmitEvidenceContext, - PreSubmitEvidenceService, - PersistedPreSubmitEvidence, - pre_submit_failure_audit_payload, - semantic_manifest_identity, - validate_predecessor_lineage, -) -from app.modules.tasks.api import ( - SubmissionPredecessorFacts, - TaskLockedProjectContextReferences, -) -from app.modules.checkers.api import ( - PreSubmissionExecutionEntryFacts, - PreSubmissionExecutionFacts, -) -from app.modules.checkers.compiler import ( - PreSubmitCheckerCompilerError, - compile_effective_project_submission_artifact_policy, -) -from app.modules.checkers.pre_submit_execution import ( - PreSubmissionEntryResult, - PreSubmissionExecutionResult, - PreSubmissionExecutionCustody, - PreSubmissionResultDefinition, - PreSubmissionResultPolicyTrace, - PreSubmissionResultStatus, - PreSubmissionInfrastructureUnavailable, - validate_pre_submission_execution_result, -) - - -def _sha(character: str) -> str: - return "sha256:" + character * 64 - - -def _context() -> PreSubmitEvidenceContext: - return PreSubmitEvidenceContext( - actor_profile_id=new_record_id(), - identity_link_id=new_record_id(), - project_id=new_record_id(), - task_id=new_record_id(), - assignment_id=new_record_id(), - predecessor_submission_id=None, - predecessor_submission_version=None, - prepared_generation_id=new_record_id(), - archive_sha256=_sha("1"), - archive_byte_count=1024, - semantic_manifest_id=new_record_id(), - semantic_manifest_sha256=_sha("2"), - guide_id=new_record_id(), - guide_version="1", - source_snapshot_id=new_record_id(), - source_snapshot_sha256=_sha("8"), - locked_guide_sha256=_sha("3"), - effective_policy_id=new_record_id(), - locked_artifact_policy_sha256=_sha("4"), - pre_submit_policy_id=new_record_id(), - locked_checker_policy_sha256=_sha("5"), - catalogue_id="workstream.pre_submission_checkers", - catalogue_version="v0.1", - catalogue_manifest_sha256=_sha("6"), - storage_scheme="s3", - ) - - -def test_evidence_operation_identity_binds_every_custody_fact() -> None: - context = _context() - identity = context.operation_identity(attempt_id=UUID(int=1), attempt_request_digest=_sha("9"), effective_plan_sha256=_sha("7")) - - assert identity == context.operation_identity(attempt_id=UUID(int=1), attempt_request_digest=_sha("9"), effective_plan_sha256=_sha("7")) - assert identity != replace(context, prepared_generation_id=new_record_id()).operation_identity( - attempt_id=UUID(int=1), attempt_request_digest=_sha("9"), effective_plan_sha256=_sha("7") - ) - assert identity != context.operation_identity(attempt_id=UUID(int=1), attempt_request_digest=_sha("9"), effective_plan_sha256=_sha("8")) - assert identity != context.operation_identity( - attempt_id=UUID(int=2), attempt_request_digest=_sha("9"), effective_plan_sha256=_sha("7"), - ) - assert identity != context.operation_identity( - attempt_id=UUID(int=1), attempt_request_digest=_sha("a"), effective_plan_sha256=_sha("7"), - ) - - -def test_post_byte_relock_rejects_advanced_predecessor_version() -> None: - predecessor_id = new_record_id() - task_context = submission_context_facts(submitter_contribution_policy_version_id=UUID(int=100), - task_id=new_record_id(), - assignment_id=new_record_id(), - contributor_id=new_record_id(), - status="needs_revision", - kind="checker_remediation", - predecessor=SubmissionPredecessorFacts( - submission_id=predecessor_id, - version=2, - ), - checker_remediation_manifest_id=new_record_id(), - locked_project_context=TaskLockedProjectContextReferences(locked_contribution_policy_version_id=UUID(int=100), - project_id=new_record_id(), - guide_version="1", - source_snapshot_id=new_record_id(), - source_snapshot_hash=_sha("1"), - effective_policy_id=new_record_id(), - effective_policy_hash=_sha("2"), - pre_submit_policy_id=new_record_id(), - pre_submit_policy_bundle_hash=_sha("3"), - ), - ) - - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_locked_context_changed"): - validate_predecessor_lineage( - task_context, - predecessor_submission_id=predecessor_id, - predecessor_submission_version=1, - checker_remediation_manifest_id=task_context.checker_remediation_manifest_id, - ) - - -def test_semantic_manifest_identity_is_server_deterministic() -> None: - assert semantic_manifest_identity(_sha("a")) == semantic_manifest_identity(_sha("a")) - assert semantic_manifest_identity(_sha("a")) != semantic_manifest_identity(_sha("b")) - - -def test_pass_capability_is_generation_bound_and_single_use() -> None: - evidence_set_id = new_record_id() - generation_id = new_record_id() - capability = PreSubmitEvidenceService( - SimpleNamespace(), - task_contexts=SimpleNamespace(), - project_contexts=SimpleNamespace(), - )._mint_pass_capability( - evidence_set_id=evidence_set_id, - prepared_generation_id=generation_id, - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=_sha("7"), - archive_sha256=_sha("1"), - semantic_manifest_sha256=_sha("2"), - storage_scheme="s3", - ) - - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_pass_capability_invalid"): - capability.consume( - prepared_generation_id=new_record_id(), - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=_sha("7"), - archive_sha256=_sha("1"), - semantic_manifest_sha256=_sha("2"), - storage_scheme="s3", - ) - - assert ( - capability.consume( - prepared_generation_id=generation_id, - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=_sha("7"), - archive_sha256=_sha("1"), - semantic_manifest_sha256=_sha("2"), - storage_scheme="s3", - ) - == evidence_set_id - ) - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_pass_capability_invalid"): - capability.consume( - prepared_generation_id=generation_id, - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=_sha("7"), - archive_sha256=_sha("1"), - semantic_manifest_sha256=_sha("2"), - storage_scheme="s3", - ) - - -def test_compiler_projects_policy_artifact_path_not_contributor_label() -> None: - effective_policy = { - "workstream_default_policy": { - "required_packet_fields": [], - "forbidden_artifacts": [{"pattern": ".env"}], - "attestation_terms": ["rights_confirmed"], - }, - "project_policy": {}, - "required_packet_fields": [], - "required_artifacts": [{"key": "answer", "path": "outputs/final.md", "required": True}], - "required_evidence": [], - "forbidden_artifacts": [{"pattern": ".env"}], - "attestation_terms": ["rights_confirmed"], - "manifest_required": False, - "artifact_hash_required": False, - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": None, - "maximum_package_size_bytes": None, - "packaging": {"package_required": False}, - } - - compiled = compile_effective_project_submission_artifact_policy(effective_policy, _sha("9")) - required = next( - rule for rule in compiled.compiled_bundle["rules"] if rule["primitive"] == "require_file" - ) - - assert required["config"] == {"artifact_paths": ["outputs/final.md"]} - - -@pytest.mark.parametrize("path", [None, "", "../answer.md", "/answer.md", "a\\b"]) -def test_compiler_rejects_unmappable_artifact_paths(path: object) -> None: - policy = { - "workstream_default_policy": { - "required_packet_fields": ["summary", "worker_attestation"], - "forbidden_artifacts": [{"pattern": ".env"}], - "attestation_terms": ["rights_confirmed"], - }, - "project_policy": {}, - "required_packet_fields": ["summary", "worker_attestation"], - "required_artifacts": [{"key": "answer", "path": path, "required": True}], - "required_evidence": [], - "forbidden_artifacts": [{"pattern": ".env"}], - "attestation_terms": ["rights_confirmed"], - "manifest_required": False, - "artifact_hash_required": False, - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": None, - "maximum_package_size_bytes": None, - "packaging": {"package_required": False}, - } - - with pytest.raises(PreSubmitCheckerCompilerError, match="path is invalid"): - compile_effective_project_submission_artifact_policy(policy, _sha("9")) - - -@pytest.mark.parametrize("key", [".", ".."]) -def test_compiler_rejects_noncanonical_evidence_keys(key: str) -> None: - policy = { - "workstream_default_policy": {}, - "project_policy": {}, - "required_packet_fields": [], - "required_artifacts": [], - "required_evidence": [{"key": key, "required": True}], - "forbidden_artifacts": [], - "attestation_terms": [], - "manifest_required": False, - "artifact_hash_required": False, - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": None, - "maximum_package_size_bytes": None, - "packaging": {"package_required": False}, - } - - with pytest.raises(PreSubmitCheckerCompilerError, match="key is unmappable"): - compile_effective_project_submission_artifact_policy(policy, _sha("9")) - - -def test_compiler_rejects_duplicate_policy_keys_and_projected_paths() -> None: - base = { - "workstream_default_policy": {}, - "project_policy": {}, - "required_packet_fields": [], - "required_evidence": [], - "forbidden_artifacts": [], - "attestation_terms": [], - "manifest_required": False, - "artifact_hash_required": False, - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": None, - "maximum_package_size_bytes": None, - "packaging": {"package_required": False}, - } - duplicate_keys = { - **base, - "required_artifacts": [ - {"key": "answer", "path": "a.md"}, - {"key": "answer", "path": "b.md"}, - ], - } - duplicate_paths = { - **base, - "required_artifacts": [ - {"key": "answer", "path": "a.md"}, - {"key": "report", "path": "a.md"}, - ], - } - - with pytest.raises(PreSubmitCheckerCompilerError, match="keys are invalid"): - compile_effective_project_submission_artifact_policy(duplicate_keys, _sha("9")) - with pytest.raises(PreSubmitCheckerCompilerError, match="paths are ambiguous"): - compile_effective_project_submission_artifact_policy(duplicate_paths, _sha("9")) - - -def test_failure_audit_projection_is_bounded_and_path_free() -> None: - actor_id = new_record_id() - project_id = new_record_id() - task_id = new_record_id() - generation_id = new_record_id() - evidence = PersistedPreSubmitEvidence(new_record_id(), _sha("1"), False) - execution = PreSubmitExecutionResult( - custody=PreSubmitExecutionCustody( - prepared_generation_id=generation_id, - archive_sha256=_sha("5"), - archive_byte_count=1, - semantic_manifest_sha256=_sha("6"), - storage_scheme="s3", - ), - checker_facts=PreSubmissionExecutionFacts( - plan_sha256=_sha("2"), - eligible=False, - entries=( - PreSubmissionExecutionEntryFacts( - dispatch_authority="workstream.pre_submission_checker_catalogue", - definition_id="policy.file.require", - definition_version="v1", - public_name="check_required_files", - policy_source="locked_effective_project_submission_artifact_policy", - effective_plan_sha256=_sha("2"), - rule_instance_id=_sha("3"), - locked_policy_sha256=_sha("4"), - phase="project_policy", - order=10, - classification="mandatory_accountability", - severity="blocking", - checker_execution_status="failed", - failure_code="pre_submission_checker_failed", - message_code="required_file_missing", - metadata=(("finding_count", 1),), - ), - ), - ), - ) - - payload = pre_submit_failure_audit_payload( - actor_profile_id=actor_id, - project_id=project_id, - task_id=task_id, - prepared_generation_id=generation_id, - evidence=evidence, - execution=execution, - catalogue_id="workstream.pre_submission_checkers", - catalogue_version="v0.1", - ) - - assert payload["event_type"] == "pre_submission_check_failed" - assert payload["failed_count"] == 1 - serialized = repr(payload) - assert payload["result_outcomes"] == [ - { - "definition_id": "policy.file.require", - "definition_version": "v1", - "status": "failed", - "message_code": "required_file_missing", - } - ] - assert "finding_count" not in serialized - assert "/" not in serialized - - -def test_result_validation_rejects_failure_code_on_non_failed_result() -> None: - from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue - from app.modules.checkers.effective_plan import ( - EffectivePreSubmissionPlanLineage, - compile_effective_pre_submission_execution_plan, - ) - - effective_policy = { - "workstream_default_policy": { - "required_packet_fields": ["summary", "worker_attestation"], - "forbidden_artifacts": [{"pattern": ".env"}], - "attestation_terms": ["rights_confirmed"], - }, - "project_policy": {}, - "required_packet_fields": ["summary", "worker_attestation"], - "required_artifacts": [], - "required_evidence": [], - "forbidden_artifacts": [{"pattern": ".env"}], - "attestation_terms": ["rights_confirmed"], - "manifest_required": False, - "artifact_hash_required": False, - "allowed_storage_schemes": ["s3"], - "maximum_file_size_bytes": None, - "maximum_package_size_bytes": None, - "packaging": {"package_required": False}, - } - policy_hash = canonical_json_hash(effective_policy) - compiled = compile_effective_project_submission_artifact_policy(effective_policy, policy_hash) - lineage = EffectivePreSubmissionPlanLineage( - project_id=new_record_id(), - guide_id=new_record_id(), - guide_version="1", - source_snapshot_id=new_record_id(), - source_snapshot_hash=_sha("a"), - effective_policy_id=new_record_id(), - effective_policy_hash=policy_hash, - pre_submit_policy_id=new_record_id(), - pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, - ) - plan = compile_effective_pre_submission_execution_plan( - lineage=lineage, - effective_policy=effective_policy, - compiled_bundle=compiled.compiled_bundle, - catalogue=build_pre_submission_checker_catalogue(), - ) - forged = PreSubmissionExecutionResult( - plan_sha256=plan.plan_sha256, - custody=PreSubmissionExecutionCustody( - prepared_generation_id=new_record_id(), - archive_sha256=_sha("b"), - archive_byte_count=1, - semantic_manifest_sha256=_sha("c"), - storage_scheme="s3", - ), - eligible=True, - entries=tuple( - PreSubmissionEntryResult( - schema_version=entry.result_schema, - definition=PreSubmissionResultDefinition( - dispatch_authority="workstream.pre_submission_checker_catalogue", - definition_id=entry.definition_id, - definition_version=entry.definition_version, - public_name=entry.public_name, - source=entry.policy_trace_source, - ), - policy_trace=PreSubmissionResultPolicyTrace( - effective_plan_sha256=plan.plan_sha256, - rule_instance_id=entry.rule_instance_id, - locked_policy_sha256=lineage.effective_policy_hash, - ), - phase=entry.phase, - order=entry.order, - classification=entry.classification, - severity="warning" if entry.classification == "advisory" else "blocking", - status=PreSubmissionResultStatus.PASSED, - failure_code="forged" if index == 0 else None, - message_code="passed", - ) - for index, entry in enumerate(plan.entries) - ), - ) - - with pytest.raises( - PreSubmissionInfrastructureUnavailable, - match="pre_submission_result_context_invalid", - ): - validate_pre_submission_execution_result(plan, forged) - - class _ForgedStatus: - value = "failed" - - forged_entry = replace( - forged.entries[0], - status=_ForgedStatus(), # type: ignore[arg-type] - failure_code=None, - ) - forged_status = replace( - forged, - eligible=True, - entries=(forged_entry, *forged.entries[1:]), - ) - - with pytest.raises( - PreSubmissionInfrastructureUnavailable, - match="pre_submission_result_context_invalid", - ): - validate_pre_submission_execution_result(plan, forged_status) - - with pytest.raises( - PreSubmissionInfrastructureUnavailable, - match="pre_submission_result_context_invalid", - ): - validate_pre_submission_execution_result(plan, replace(forged, eligible=1)) # type: ignore[arg-type] - - for metadata in ( - (("finding_count",),), - ((["finding_count"], 1),), - (("unknown_count", 1),), - (("finding_count", 1), ("finding_count", 2)), - (("finding_count", "1"),), - (("finding_count", -1),), - ): - malformed_entry = replace( - forged.entries[0], - failure_code=None, - metadata=metadata, # type: ignore[arg-type] - ) - malformed = replace( - forged, - entries=(malformed_entry, *forged.entries[1:]), - ) - with pytest.raises( - PreSubmissionInfrastructureUnavailable, - match="pre_submission_result_context_invalid", - ): - validate_pre_submission_execution_result(plan, malformed) diff --git a/backend/tests/test_post_submit_materialization.py b/backend/tests/test_post_submit_materialization.py index f8cf72671..14a63ae39 100644 --- a/backend/tests/test_post_submit_materialization.py +++ b/backend/tests/test_post_submit_materialization.py @@ -1,6 +1,7 @@ """Verified exact Submission input, async lifetime and fail-closed composition.""" import asyncio +import json import pickle from dataclasses import asdict @@ -12,10 +13,17 @@ PostSubmissionMaterializationUnavailable, PostSubmissionMaterializationFailure, ) from tests.checkers.post_submit.support import change_request -from tests.checkers.post_submit.test_result_contract import result +from tests.checkers.execution.support import selected_result as result from tests.post_submit_materialization_helpers import material_fixture from tests.checkers.execution.support import material_execution, service_link_state from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.preparation import ArtifactScratchIntegrityError +from app.modules.checkers.api.external import ( + PostSubmitExternalCheckerIdentity, + external_checker_json_hash, + make_external_checker_execution_request, +) +from tests.checkers.external.support import registry_entry class Consumer: @@ -42,6 +50,70 @@ async def evaluate(self, request, view): return result(request) +class ExternalGrantConsumer(Consumer): + """Exercise the real POST lineage through ART's callback-scoped grant.""" + + def __init__(self, files, execution, scratch): + super().__init__(files) + self.execution = execution + self.scratch = scratch + self.grant_path = None + self.manifest = None + + def external_request(self, material, *, identity=None): + source, lease = self.execution.request, self.execution.lease + reservation = lease.reservation + identity = identity or PostSubmitExternalCheckerIdentity( + project_id=source.project_id, + task_id=source.task_id, + assignment_id=source.assignment_id, + submission_id=source.submission_id, + submission_version=source.submission_version, + evaluation_request_id=source.evaluation_request_id, + evaluation_request_digest=source.request_sha256, + evaluation_generation=source.evaluation_generation, + attempt_id=reservation.attempt_id, + result_id=reservation.result_id, + lease_id=lease.lease_id, + lease_generation=lease.lease_generation, + lease_expires_at=lease.expires_at, + ) + return make_external_checker_execution_request( + registry=registry_entry("post_submit"), + identity=identity, + configuration={}, + configuration_sha256=external_checker_json_hash({}), + input={"task_version": "v1"}, + input_sha256=external_checker_json_hash({"task_version": "v1"}), + materials=(material,), + ) + + async def evaluate(self, request, view): + await super().evaluate(request, view) + external_request = self.external_request(view.external_checker_material) + with view.external_checker_grant(external_request) as grant: + self.grant_path = self.scratch / "workspaces" / grant.grant_id + self.manifest = json.loads( + (self.grant_path / ".external-checker-grant.json").read_text() + ) + assert self.manifest["phase"] == "post_submit" + assert self.manifest["submission_id"] == str(request.submission_id) + assert self.manifest["request_digest"] == external_request.request_digest + assert self.manifest["binding_digest"] == grant.binding_digest + # Revocation removes the service grant immediately; the outer material + # callback still owns its workspace until materialization completes. + assert self.grant_path.is_dir() + assert not (self.grant_path / ".external-checker-grant.json").exists() + + changed = view.external_checker_material.model_copy( + update={"binding_id": new_record_id()} + ) + with pytest.raises(ArtifactScratchIntegrityError, match="material identity differs"): + with view.external_checker_grant(self.external_request(changed)): + pytest.fail("substituted material acquired an external grant") + return result(request) + + def assert_closed(h, consumer): with pytest.raises(RuntimeError, match="closed"): _ = consumer.view.entries @@ -91,6 +163,18 @@ async def test_exact_materialization_reads_verified_original_and_revokes_view(tm "input_materialization_evidence_id"} +async def test_post_external_grant_binds_native_submission_art_and_lease(tmp_path, isolated_database_env): + async with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) + consumer = ExternalGrantConsumer(h.files, execution, h.scratch) + material = await h.service.materialize(execution, consumer) + assert material.evaluation.outcome == "completed" + assert consumer.manifest["lease_id"] == str(execution.lease.lease_id) + assert consumer.manifest["content_id"] == str(material.content_id) + assert consumer.grant_path is not None and not consumer.grant_path.exists() + assert_closed(h, consumer) + + async def test_revoked_materializer_denies_before_provider_or_scratch(tmp_path, isolated_database_env): async with material_fixture(tmp_path, isolated_database_env) as h: execution = await material_execution(h) @@ -149,8 +233,8 @@ async def test_async_exit_and_concurrent_drift_never_return_stale_material(tmp_p assert_closed(h, consumer) -async def test_wrong_provider_bytes_or_manifest_never_reach_consumer(tmp_path, isolated_database_env): - from tests.test_default_pre_submit_execution import _bytes +async def test_wrong_provider_bytes_never_reach_consumer(tmp_path, isolated_database_env): + from tests.archive_fixtures import byte_source as _bytes async with material_fixture(tmp_path, isolated_database_env) as h: execution = await material_execution(h) consumer = Consumer(h.files) @@ -159,12 +243,6 @@ async def test_wrong_provider_bytes_or_manifest_never_reach_consumer(tmp_path, i with pytest.raises(PostSubmissionMaterializationFailure, match="material_unavailable"): await h.service.materialize(execution, consumer) h.store.open = original - packet = h.request.structural_input.model_copy(update={"manifest": ()}) - h.request = change_request(h.request, structural_input=packet, - evaluation_request_id=new_record_id(), evaluation_generation=2) - execution = await material_execution(h) - with pytest.raises(PostSubmissionMaterializationFailure, match="manifest_mismatch"): - await h.service.materialize(execution, consumer) assert consumer.calls == 0 and not h.preparation._active @@ -355,7 +433,6 @@ async def capture(service, request): ] async with h.factory() as session: body = await session.scalar(text( - "select e.semantic_manifest_body from pre_submit_evidence_sets e " - "join submission_bundle_admissions a on a.pre_submit_evidence_set_id=e.id where a.id=:id" + "select semantic_manifest_body from submission_bundle_admissions where id=:id" ), {"id": str(h.created.admission_id)}) assert body == h.manifest.as_dict() diff --git a/backend/tests/test_post_submit_selection.py b/backend/tests/test_post_submit_selection.py index b4352eb8a..55fff9acf 100644 --- a/backend/tests/test_post_submit_selection.py +++ b/backend/tests/test_post_submit_selection.py @@ -33,7 +33,12 @@ def observe(connection, cursor, statement, parameters, context, executemany): assert "workstream_tasks.project_id =" in statement assert "submissions.task_id =" in statement and "submissions.id =" in statement assert "FOR UPDATE" not in statement - assert "package_uri" not in statement and "policy_body" not in statement + assert "package_uri" not in statement + assert "selected_post_submit_policy_body" in statement + assert not any(field in statement for field in ( + "locked_post_submit_checker_policy_body", "locked_review_policy_body", + "locked_revision_policy_body", + )) assert "worker_attestation" not in statement and "summary" not in statement finally: event.remove(h.engine.sync_engine, "before_cursor_execute", observe) @@ -58,6 +63,8 @@ def observe(connection, cursor, statement, parameters, context, executemany): "review_generation": "locked_review_policy_generation", "review_hash": "locked_review_policy_hash", "revision_policy_id": "locked_revision_policy_id", "revision_generation": "locked_revision_policy_generation", "revision_hash": "locked_revision_policy_hash", + "selected_post_policy_hash": "selected_post_submit_policy_sha256", + "selected_post_policy_body": "selected_post_submit_policy_body", } assert {key: str(value) for key, value in asdict(facts.context).items()} == { key: str(row[column]) for key, column in mapping.items() @@ -69,11 +76,8 @@ async def test_frozen_context_substitution_rejected_by_art_selection(tmp_path, i for name in ("source_id", "effective_policy_id", "pre_policy_id", "post_policy_id", "review_policy_id", "revision_policy_id", "review_generation", "revision_hash"): value = (2 if name == "review_generation" else "sha256:" + "0" * 64 if name == "revision_hash" else new_record_id()) expected = h.request.expected_context.model_copy(update={name: value}) - packet = h.request.structural_input.model_copy(update={ - "observed_context": h.request.structural_input.observed_context.model_copy(update={name: value}), - }) with pytest.raises(PostSubmissionMaterializationUnavailable, match="identity_mismatch"): - await select_material(h, change_request(h.request, expected_context=expected, structural_input=packet)) + await select_material(h, change_request(h.request, expected_context=expected)) assert not h.store.opens and not h.preparation._active selected = await select_material(h, h.request) assert selected.submission.admission_id == h.created.admission_id @@ -111,8 +115,7 @@ def forbidden(*args, **kwargs): # Keep duplicated project/policy facts schema-valid so # rejection must come from persisted owner selection. mix.update(policy=foreign.request.policy, - expected_context=foreign.request.expected_context, - structural_input=foreign.request.structural_input) + expected_context=foreign.request.expected_context) mixed = change_request(own.request, **mix) with pytest.raises((PostSubmissionMaterializationUnavailable, SubmittedBundleUnavailable)): await select_material(own, mixed) diff --git a/backend/tests/test_pre_submit_attempt_authority_integration.py b/backend/tests/test_pre_submit_attempt_authority_integration.py deleted file mode 100644 index 06821dbd3..000000000 --- a/backend/tests/test_pre_submit_attempt_authority_integration.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Integrated ART replay proof for original-generation fixed-service authority.""" - -from dataclasses import replace -from pathlib import Path - -import pytest - -from app.adapters.artifacts import CheckerPhaseService -from tests.checkers.execution.support import forbidden_post_submission -from sqlalchemy import select - -from app.modules.artifacts.authorization import PreparedPreSubmitMaterializationAuthorization -from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidencePersistenceResult -from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError -from app.modules.authorization.catalogue import ActionId -from app.modules.tasks.models import AuditEvent -from tests.authorization.test_pre_submit_attempt_authority import _seed_materializer -from tests.test_pre_submit_attempt_recovery import _harness - - -async def _reserve_with_real_materializer(workflow, request, preparation_request): - """Issue the exact transaction-bound handle consumed by ART reservation.""" - materialization = workflow._materialization - async with workflow._session.begin(): - await workflow._lock_authorized_context(request, preparation_request) - handle = await materialization.prepare_authorization( - task_id=request.task_id, assignment_id=request.assignment_id, - submission_artifact_policy_id=request.submission_artifact_policy_id, - checker_policy_id=request.checker_policy_id, - prepared_artifact=request.prepared_artifact, - effective_plan=request.effective_plan, - idempotency_key=preparation_request.idempotency_key, - ) - return await workflow.reserve( - replace(request, prepared_authorization=handle), - preparation_request=preparation_request, - ) - - -async def _materializer_audits(factory): - async with factory() as session: - return (await session.scalars(select(AuditEvent).where( - AuditEvent.action_id - == ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE.value, - ).order_by(AuditEvent.created_at, AuditEvent.id))).all() - - -@pytest.mark.asyncio -async def test_completed_replay_authorizes_retry_custody_and_stored_original_generation( - tmp_path: Path, isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - calls: list[int] = [] - retry = None - try: - await _seed_materializer(harness.factory) - async with harness.factory() as session: - contributor = harness.contributor_authority(session) - await contributor.preflight(request=harness.preparation_request) - async with session.begin(): - await contributor.revalidate( - request=harness.preparation_request, - project_id=harness.request.effective_plan.lineage.project_id, - ) - authority = PreparedPreSubmitMaterializationAuthorization( - session, request_id=harness.preparation_request.request_id, - correlation_id=harness.preparation_request.correlation_id, - ) - workflow = harness.workflow( - session, calls, preparation_authorization=contributor, - ) - workflow._materialization._authorization = authority - phases = CheckerPhaseService( - pre_submission=workflow, post_submission=forbidden_post_submission(), - ) - try: - reservation = await _reserve_with_real_materializer( - workflow, harness.request, harness.preparation_request, - ) - original = await phases.evaluate_pre_submission( - replace(harness.request, prepared_authorization=None), reservation, - preparation_request=harness.preparation_request, - ) - assert original.pass_capability is not None - assert len(calls) == 1 - original_generation = original.execution.custody.prepared_generation_id - initial_audits = await _materializer_audits(harness.factory) - assert [event.resource_id for event in initial_audits] == [ - str(original_generation), str(original_generation), - ] - - await harness.request.prepared_artifact.close() - retry = await harness.fresh_request() - retry_generation = retry.prepared_artifact.generation_id - assert retry_generation != original_generation - - # A failed original-generation AUTH consume must abort recovery. - # The inspected retry consume has already passed in that root - # transaction, but both its audit and the replay read roll back. - real_consume = authority.consume - - async def deny_original(*, prepared_authorization, facts): - if facts.prepared_generation_id == original_generation: - facts = replace(facts, archive_sha256="sha256:" + "f" * 64) - await real_consume( - prepared_authorization=prepared_authorization, facts=facts, - ) - - authority.consume = deny_original - try: - with pytest.raises(ArtifactAuthorityDeniedError): - await _reserve_with_real_materializer( - workflow, retry, harness.preparation_request, - ) - finally: - authority.consume = real_consume - assert len(calls) == 1 - assert len(await _materializer_audits(harness.factory)) == 2 - - # The injected invalid consume intentionally leaves its exact - # handle unconsumed. A subsequent request gets a new adapter. - authority.close() - authority = PreparedPreSubmitMaterializationAuthorization( - session, request_id=harness.preparation_request.request_id, - correlation_id=harness.preparation_request.correlation_id, - ) - workflow._materialization._authorization = authority - replay = await _reserve_with_real_materializer( - workflow, retry, harness.preparation_request, - ) - selected = replay - replay = await phases.evaluate_pre_submission( - replace(retry, prepared_authorization=None), selected, - preparation_request=harness.preparation_request, - ) - assert replay is selected - assert isinstance(replay, PreSubmitEvidencePersistenceResult) - assert replay.evidence.evidence_set_id == original.evidence.evidence_set_id - assert replay.execution.custody.prepared_generation_id == original_generation - assert replay.pass_capability is None - assert len(calls) == 1 - audits = await _materializer_audits(harness.factory) - assert len(audits) == 4 - assert sorted(event.resource_id for event in audits) == sorted([ - str(original_generation), str(original_generation), - str(original_generation), str(retry_generation), - ]) - assert all(event.event_type == "SensitiveAuthorizationAllowed" for event in audits) - finally: - authority.close() - finally: - if retry is not None: - await retry.prepared_artifact.close() - await harness.close() diff --git a/backend/tests/test_pre_submit_attempt_contracts.py b/backend/tests/test_pre_submit_attempt_contracts.py deleted file mode 100644 index b5e4f5dc1..000000000 --- a/backend/tests/test_pre_submit_attempt_contracts.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Negative boundaries for ART attempt identity, replay and invocation claims.""" - -from dataclasses import replace -import pickle -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock -from uuid import UUID -from app.core.identifiers import new_record_id - -import pytest - -from app.core.hashing import canonical_json_hash -from app.modules.artifacts.pre_submit_attempts import ( - PreSubmitAttemptClaim, - PreSubmitAttemptStore, - logical_request, -) -from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidenceConflict -from app.modules.artifacts.submission_materialization import ( - PreparedBundleMaterializationRequest, - PreparedBundleMaterializationService, -) -from app.modules.checkers.api import SubmissionPacketView -from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue -from tests.test_default_pre_submit_execution import _plan, _request -from tests.test_effective_pre_submit_execution import _context -from tests.test_submission_manifest import _archive, _manifest - - -def test_attempt_claim_cannot_be_constructed_or_serialized() -> None: - with pytest.raises(TypeError, match="issued by ART"): - PreSubmitAttemptClaim() - with pytest.raises(TypeError, match="cannot be serialized"): - pickle.dumps(object.__new__(PreSubmitAttemptClaim)) - - -def test_logical_attempt_identity_binds_packet_policy_and_archive_but_not_retry_generation() -> ( - None -): - context = _context() - plan = _plan(build_pre_submission_checker_catalogue()) - packet = SimpleNamespace(summary="Original work", contributor_attestation="rights_confirmed") - - def digest(current_context=context, current_packet=packet): - # logical_request consumes a dataclass packet; use the real typed view. - from app.modules.checkers.api import SubmissionPacketView - - view = SubmissionPacketView( - summary=current_packet.summary, - contributor_attestation=current_packet.contributor_attestation, - ) - return canonical_json_hash(logical_request(current_context, plan, view)) - - original = digest() - assert digest(replace(context, prepared_generation_id=new_record_id())) == original - assert ( - digest( - current_packet=SimpleNamespace( - summary="Changed work", - contributor_attestation=packet.contributor_attestation, - ) - ) - != original - ) - assert digest(replace(context, locked_checker_policy_sha256="sha256:" + "e" * 64)) != original - assert digest(replace(context, archive_sha256="sha256:" + "f" * 64)) != original - - -@pytest.mark.asyncio -async def test_initial_attempt_reservation_uses_fresh_record_identity(monkeypatch) -> None: - context = _context() - plan = _plan(build_pre_submission_checker_catalogue()) - packet = SubmissionPacketView( - summary="Completed exact work.", - contributor_attestation="rights_confirmed", - ) - request = PreparedBundleMaterializationRequest( - prepared_authorization=object(), - task_id=context.task_id, - assignment_id=context.assignment_id, - submission_artifact_policy_id=plan.lineage.effective_policy_id, - checker_policy_id=plan.lineage.pre_submit_policy_id, - predecessor_submission_version=None, - prepared_artifact=SimpleNamespace(generation_id=context.prepared_generation_id), - effective_plan=plan, - inspection=object(), - manifest=object(), - change_gate=object(), - packet=packet, - ) - attempt_id = UUID("018f0000-0000-7000-8000-000000000001") - nonce = UUID("62c5f90b-1a80-4c62-8ba6-ec76168e2d73") - monkeypatch.setattr( - "app.modules.artifacts.pre_submit_attempts.new_record_id", - lambda: attempt_id, - ) - monkeypatch.setattr( - "app.modules.artifacts.pre_submit_attempts.uuid4", - lambda: nonce, - ) - session = SimpleNamespace( - in_transaction=lambda: True, - in_nested_transaction=lambda: False, - scalar=AsyncMock(return_value=str(attempt_id)), - ) - - reservation = await PreSubmitAttemptStore(session).reserve( - context=context, - plan=plan, - packet=packet, - idempotency_key=new_record_id(), - request=request, - ) - - assert reservation.attempt_id == attempt_id - assert reservation.claim_nonce == nonce - assert reservation.attempt_id.version == 7 - assert reservation.claim_nonce.version == 4 - values = session.scalar.await_args.args[0].compile().params - assert values["id"] == str(attempt_id) - assert values["claim_nonce"] == str(nonce) - - -def _claim(request, session, *, spent=False, foreign=False): - claim = object.__new__(PreSubmitAttemptClaim) - claim.attempt_id, claim._nonce = new_record_id(), new_record_id() - claim.request_digest = "sha256:" + "1" * 64 - claim._request = replace(request, prepared_authorization=None) - claim._started, claim._execution = spent, None - claim._session = object() if foreign else session - return claim - - -@pytest.mark.parametrize("claim_kind", ("absent", "foreign", "spent", "missing_reservation")) -async def test_invalid_attempt_claim_never_builds_processor_or_opens_workspace( - tmp_path, - claim_kind: str, -) -> None: - request, _inspector, manager, _preparation, catalogue = await _request(tmp_path) - session = SimpleNamespace( - in_transaction=lambda: True, - in_nested_transaction=lambda: False, - scalar=AsyncMock(return_value=None), - ) - authorization = SimpleNamespace(consume=AsyncMock()) - processor_build = Mock(side_effect=AssertionError("invalid claim built CHECKER processor")) - preparation = SimpleNamespace( - _process_prepared_submission=AsyncMock( - side_effect=AssertionError("invalid claim opened scratch"), - ) - ) - service = PreparedBundleMaterializationService( - session=session, - authorization=authorization, - preparation=preparation, - checker_execution=SimpleNamespace( - catalogue_manifest_sha256=catalogue.manifest_sha256, - build=processor_build, - ), - storage_scheme="s3", - ) - claim = { - "absent": None, - "foreign": _claim(request, session, foreign=True), - "spent": _claim(request, session, spent=True), - "missing_reservation": _claim(request, session), - }[claim_kind] - try: - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_attempt_claim_invalid"): - await service.materialize_prepared_bundle(request, claim=claim) - processor_build.assert_not_called() - preparation._process_prepared_submission.assert_not_awaited() - assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] - finally: - await request.prepared_artifact.close() - manager.close() - - -@pytest.mark.parametrize("missing_field", ("metadata_json", "checker_order")) -async def test_completed_replay_rejects_missing_result_metadata_before_capability( - missing_field: str, -) -> None: - plan = _plan(build_pre_submission_checker_catalogue()) - attempt_id, evidence_id = str(new_record_id()), str(new_record_id()) - request_digest = "sha256:" + "a" * 64 - manifest = _manifest(_archive([("answer.txt", b"verified")])) - evidence = SimpleNamespace( - semantic_manifest_body=manifest.as_dict(), - id=evidence_id, - attempt_id=attempt_id, - attempt_request_digest=request_digest, - packet_sha256="sha256:" + "b" * 64, - semantic_manifest_sha256=manifest.sha256, - ) - row = SimpleNamespace( - id=attempt_id, - status="completed", - evidence_set_id=evidence_id, - request_json={"packet_sha256": "sha256:" + "b" * 64}, - request_digest=request_digest, - ) - members = [ - SimpleNamespace(metadata_json=[], checker_order=entry.order) for entry in plan.entries - ] - setattr(members[0], missing_field, None) - session = SimpleNamespace( - get=AsyncMock(return_value=evidence), - scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: members)), - ) - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_attempt_result_unavailable"): - await PreSubmitAttemptStore(session).read_completed(row=row, context=_context(), plan=plan) - - -@pytest.mark.parametrize("corruption", ("definition_order", "metadata")) -async def test_completed_replay_rejects_nonnull_corrupt_result_before_capability( - corruption: str, -) -> None: - plan = _plan(build_pre_submission_checker_catalogue()) - context = _context() - attempt_id, evidence_id = str(new_record_id()), str(new_record_id()) - request_digest = "sha256:" + "a" * 64 - manifest = _manifest(_archive([("answer.txt", b"verified")])) - evidence = SimpleNamespace( - semantic_manifest_body=manifest.as_dict(), - id=evidence_id, - attempt_id=attempt_id, - attempt_request_digest=request_digest, - packet_sha256="sha256:" + "b" * 64, - archive_sha256=context.archive_sha256, - archive_byte_count=context.archive_byte_count, - semantic_manifest_sha256=manifest.sha256, - storage_scheme=context.storage_scheme, - effective_plan_sha256=plan.plan_sha256, - eligible=True, - ) - row = SimpleNamespace( - id=attempt_id, - status="completed", - evidence_set_id=evidence_id, - request_json={"packet_sha256": "sha256:" + "b" * 64}, - request_digest=request_digest, - prepared_generation_id=str(context.prepared_generation_id), - ) - members = [ - SimpleNamespace( - dispatch_authority="workstream.pre_submission_checker_catalogue", - definition_id=entry.definition_id, - definition_version=entry.definition_version, - public_name=entry.public_name, - source=entry.policy_trace_source, - effective_plan_sha256=plan.plan_sha256, - rule_instance_id=entry.rule_instance_id, - locked_policy_sha256=plan.lineage.effective_policy_hash, - phase=entry.phase, - checker_order=entry.order, - classification=entry.classification, - severity="warning" if entry.classification == "advisory" else "blocking", - status="passed", - failure_code=None, - message_code="passed", - metadata_json=[], - ) - for entry in plan.entries - ] - if corruption == "definition_order": - members[0].checker_order += 1 - else: - members[0].metadata_json = [["unregistered_count", 1]] - session = SimpleNamespace( - get=AsyncMock(return_value=evidence), - scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: members)), - ) - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submission_result_context_invalid"): - await PreSubmitAttemptStore(session).read_completed(row=row, context=context, plan=plan) diff --git a/backend/tests/test_pre_submit_attempt_lock_order.py b/backend/tests/test_pre_submit_attempt_lock_order.py deleted file mode 100644 index 7703dc414..000000000 --- a/backend/tests/test_pre_submit_attempt_lock_order.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Real PostgreSQL proof of shared-project pre-submit AUTH/ART lock order.""" - -from __future__ import annotations - - -import asyncio -from contextlib import asynccontextmanager -from dataclasses import dataclass, replace -from pathlib import Path -from typing import Any -from app.core.identifiers import new_record_id - -import pytest - -from app.adapters.artifacts import CheckerPhaseService -from tests.checkers.execution.support import forbidden_post_submission -from sqlalchemy import func, select, text -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from app.modules.artifacts.authorization import ( - PreparedPreSubmitMaterializationAuthorization, - PreparedSubmissionBundlePreparationAuthorization, -) -from app.modules.artifacts.models import PreSubmitEvidenceSet, PreSubmitExecutionAttempt -from app.modules.artifacts.submission_admission import ( - PreparedSubmissionBundlePreparationCommand, - SubmissionBundlePreparationRuntime, -) -from app.modules.authorization.runtime import ( - ActorKind, ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, -) -from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test -from tests.authorization.test_pre_submit_attempt_authority import _seed_materializer -from tests.pre_submit_test_helpers import submission_preparation_request -from tests.submission_preparation_auth_helpers import install_submitter_grant -from tests.test_default_pre_submit_execution import _archive, _bytes -from tests.test_pre_submit_attempt_authority_integration import _reserve_with_real_materializer -from tests.test_pre_submit_attempt_recovery import _harness - - -class _AfterEvidenceCommitted(Exception): - """Stop before durable provider work after the real B evidence transaction.""" - - -class _PauseAfterSpool: - def __init__(self, preparation, spooled: asyncio.Event, resume: asyncio.Event): - self._preparation = preparation - self._spooled, self._resume = spooled, resume - - async def prepare(self, byte_source, *, media_type): - prepared = await self._preparation.prepare(byte_source, media_type=media_type) - self._spooled.set() - try: - await self._resume.wait() - except BaseException: - await prepared.close() - raise - return prepared - - -class _PauseAfterProjectLock: - def __init__(self, repository, session, locked: asyncio.Event, resume: asyncio.Event): - self._repository, self._session = repository, session - self._locked, self._resume = locked, resume - self.backend_pid: int | None = None - self._paused = False - - async def lock_locked_policy_context(self, request): - facts = await self._repository.lock_locked_policy_context(request) - if not self._paused: - self._paused = True - self.backend_pid = int(await self._session.scalar(text("select pg_backend_pid()"))) - self._locked.set() - await self._resume.wait() - return facts - - -class _CompleteBeforeDurable: - def __init__(self, workflow): - self._workflow = workflow - self.evidence = None - - async def reserve(self, request, *, preparation_request): - return await self._workflow.reserve(request, preparation_request=preparation_request) - - async def execute_reserved(self, request, reservation, *, preparation_request): - self.evidence = await self._workflow.execute_reserved( - request, reservation, preparation_request=preparation_request, - ) - raise _AfterEvidenceCommitted - - -async def _seed_second_contributor_task(harness): - """Share PROJECT locks while avoiding actor, grant, task and assignment collisions.""" - actor_id, link_id, task_id, assignment_id = (new_record_id() for _ in range(4)) - project_id = harness.request.effective_plan.lineage.project_id - async with harness.engine.begin() as connection: - await connection.execute(text( - "insert into actor_profiles " - "(id,actor_kind,status,provisioning_method,created_by) values " - "(:actor,'human','active','automatic_first_access','test')" - ), {"actor": str(actor_id)}) - await connection.execute(text( - "insert into actor_identity_links " - "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by,last_verified_at) " - "values (:link,:actor,'flow-test',:subject,'human','active','test',now())" - ), {"actor": str(actor_id), "subject": str(actor_id), "link": str(link_id)}) - await seed_started_task_for_artifact_test(connection, { - "project": str(project_id), "task": str(task_id), - "assignment": str(assignment_id), "actor": str(actor_id), - }) - await install_submitter_grant(connection, { - "project": str(project_id), "actor": str(actor_id), - }) - return actor_id, link_id, task_id, assignment_id - - -def _contributor_authority(session, request): - actor = request.actor - return PreparedSubmissionBundlePreparationAuthorization( - session, - HumanAuthorizationContext( - actor_profile_id=actor.actor_profile_id, - actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, - identity_link_id=actor.identity_link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=request.request_id, - correlation_id=request.correlation_id, - ), - ) - - -def _second_preparation_request(harness, actor_id, link_id, task_id, assignment_id): - template = replace(harness.request, task_id=task_id, assignment_id=assignment_id) - return replace( - submission_preparation_request( - template, actor_profile_id=actor_id, identity_link_id=link_id, - ), - byte_source=_bytes(_archive(evidence_path=harness.evidence_path)), - ) - - -@dataclass(slots=True) -class _RaceParticipants: - first_workflow: Any - second_command: Any - second_request: Any - evidence_owner: _CompleteBeforeDurable - pause_project: _PauseAfterProjectLock - first_materializer: PreparedPreSubmitMaterializationAuthorization - second_materializer: PreparedPreSubmitMaterializationAuthorization - first_calls: list[int] - second_calls: list[int] - - -async def _configure_participants( - harness, first_session, second_session, second_request, - spooled, resume_spool, project_locked, resume_project, -) -> _RaceParticipants: - first_contributor = _contributor_authority(first_session, harness.preparation_request) - await first_contributor.preflight(request=harness.preparation_request) - async with first_session.begin(): - await first_contributor.revalidate( - request=harness.preparation_request, - project_id=harness.request.effective_plan.lineage.project_id, - ) - first_materializer = PreparedPreSubmitMaterializationAuthorization( - first_session, - request_id=harness.preparation_request.request_id, - correlation_id=harness.preparation_request.correlation_id, - ) - first_calls: list[int] = [] - first_workflow = harness.workflow( - first_session, first_calls, preparation_authorization=first_contributor, - ) - first_workflow._materialization._authorization = first_materializer - second_contributor = _contributor_authority(second_session, second_request) - second_materializer = PreparedPreSubmitMaterializationAuthorization( - second_session, - request_id=second_request.request_id, - correlation_id=second_request.correlation_id, - ) - second_calls: list[int] = [] - second_workflow = harness.workflow( - second_session, second_calls, preparation_authorization=second_contributor, - ) - second_workflow._materialization._authorization = second_materializer - evidence_owner = _CompleteBeforeDurable(second_workflow) - from app.adapters.checkers import submission_evaluation_content - runtime = SubmissionBundlePreparationRuntime( - evaluation_content=submission_evaluation_content, - preparation=_PauseAfterSpool(harness.preparation, spooled, resume_spool), - inspector=harness.inspector, - catalogue=harness.catalogue, - materialization=second_workflow._materialization, - evidence=evidence_owner, - checker_service=CheckerPhaseService( - pre_submission=evidence_owner, - post_submission=forbidden_post_submission(), - ), - durable_put=object(), - ) - - @asynccontextmanager - async def runtime_factory(): - yield runtime - - second_command = PreparedSubmissionBundlePreparationCommand( - session=second_session, - authority=second_contributor, - task_contexts=second_workflow._task_contexts, - project_contexts=second_workflow._project_contexts, - runtime_factory=runtime_factory, - ) - pause_project = _PauseAfterProjectLock( - first_workflow._project_contexts, - first_session, project_locked, resume_project, - ) - return _RaceParticipants( - first_workflow, second_command, second_request, evidence_owner, - pause_project, first_materializer, second_materializer, - first_calls, second_calls, - ) - - -async def _wait_for_project_block(engine, *, application_name: str, blocker_pid: int): - """Observe the competing command's actual PostgreSQL row-lock wait.""" - async with engine.connect() as observer: - async with asyncio.timeout(8): - while True: - await observer.execute(text("select pg_stat_clear_snapshot()")) - waits = (await observer.execute(text( - "select wait_event_type,pg_blocking_pids(pid) from pg_stat_activity " - "where application_name=:application and datname=current_database()" - ), {"application": application_name})).all() - if any(kind == "Lock" and blocker_pid in blocked_by - for kind, blocked_by in waits): - return - await asyncio.sleep(0.02) - - -@pytest.mark.asyncio -async def test_same_project_reservation_and_execution_complete_without_auth_project_deadlock( - tmp_path: Path, isolated_database_env: str, monkeypatch, -) -> None: - # The fixture's ordinary ten-second scratch deadline is too short for an - # intentional database barrier; only this test extends the temporary lease. - import tests.test_default_pre_submit_execution as default_tests - - original_limits = default_tests._limits - monkeypatch.setattr(default_tests, "_limits", lambda: replace( - original_limits(), total_deadline_seconds=90, reservation_ttl_seconds=120, - aggregate_reserved_bytes=4 * default_tests.HARD_MAXIMUM_ARTIFACT_BYTES, - )) - harness = await _harness(tmp_path, isolated_database_env) - second_engine = None - first_task = second_task = None - resume_spool, resume_project = asyncio.Event(), asyncio.Event() - try: - second_actor, second_link, second_task_id, second_assignment_id = ( - await _seed_second_contributor_task(harness) - ) - await _seed_materializer(harness.factory) - second_request = _second_preparation_request( - harness, second_actor, second_link, second_task_id, second_assignment_id, - ) - application_name = f"pol07a_lock_order_{new_record_id().hex[:12]}" - second_engine = create_async_engine( - isolated_database_env, - connect_args={"server_settings": {"application_name": application_name}}, - ) - second_factory = async_sessionmaker(second_engine, expire_on_commit=False) - spooled, project_locked = asyncio.Event(), asyncio.Event() - async with harness.factory() as first_session, second_factory() as second_session: - participants = await _configure_participants( - harness, first_session, second_session, second_request, - spooled, resume_spool, project_locked, resume_project, - ) - try: - first_reservation = await _reserve_with_real_materializer( - participants.first_workflow, - harness.request, harness.preparation_request, - ) - participants.first_workflow._project_contexts = participants.pause_project - second_task = asyncio.create_task( - participants.second_command.prepare(second_request) - ) - await asyncio.wait_for(spooled.wait(), timeout=30) - first_task = asyncio.create_task(participants.first_workflow.execute_reserved( - harness.request, first_reservation, - preparation_request=harness.preparation_request, - )) - await asyncio.wait_for(project_locked.wait(), timeout=30) - assert participants.pause_project.backend_pid is not None - resume_spool.set() - await _wait_for_project_block( - harness.engine, - application_name=application_name, - blocker_pid=participants.pause_project.backend_pid, - ) - resume_project.set() - first_outcome, second_outcome = await asyncio.wait_for( - asyncio.gather(first_task, second_task, return_exceptions=True), - timeout=30, - ) - assert not isinstance(first_outcome, BaseException), first_outcome - assert isinstance(second_outcome, _AfterEvidenceCommitted), second_outcome - assert participants.evidence_owner.evidence is not None - assert first_outcome.pass_capability is not None - assert participants.evidence_owner.evidence.pass_capability is not None - assert participants.first_calls == [1] - assert participants.second_calls == [1] - finally: - participants.first_materializer.close() - participants.second_materializer.close() - async with harness.factory() as session: - attempts = (await session.scalars(select(PreSubmitExecutionAttempt))).all() - assert len(attempts) == 2 - assert {attempt.actor_profile_id for attempt in attempts} == { - str(harness.actor_id), str(second_actor), - } - assert all(attempt.status == "completed" for attempt in attempts) - assert await session.scalar(select(func.count()).select_from(PreSubmitEvidenceSet)) == 2 - finally: - resume_spool.set() - resume_project.set() - await asyncio.gather( - *(task for task in (first_task, second_task) if task is not None), - return_exceptions=True, - ) - if second_engine is not None: - await second_engine.dispose() - await harness.close() diff --git a/backend/tests/test_pre_submit_attempt_recovery.py b/backend/tests/test_pre_submit_attempt_recovery.py deleted file mode 100644 index cbea23a6e..000000000 --- a/backend/tests/test_pre_submit_attempt_recovery.py +++ /dev/null @@ -1,613 +0,0 @@ -"""Real PostgreSQL and scratch proof for ART pre-submit invocation custody.""" - -from __future__ import annotations - -from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test - -import asyncio -from dataclasses import asdict, dataclass, replace -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock -from uuid import UUID -from app.core.identifiers import new_record_id - -import pytest -from sqlalchemy import func, select, text -from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from app.core.hashing import canonical_json_hash -from app.modules.artifacts.models import ( - PreSubmitEvidenceResult, PreSubmitEvidenceSet, PreSubmitExecutionAttempt, -) -from app.modules.artifacts.authorization import PreparedSubmissionBundlePreparationAuthorization -from app.modules.artifacts.pre_submit_attempts import PreSubmitAttemptClaim -from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError -from app.modules.artifacts.pre_submit_evidence import ( - PreSubmitEvidenceConflict, - PreSubmitEvidencePersistenceResult, - PreSubmitEvidenceService, -) -from app.modules.artifacts.submission_manifest import ( - build_submission_manifest, - evaluate_submission_change, -) -from tests.pre_submit_test_helpers import ( - approved_pre_submit_fixture, - evidence_workflow, - submission_preparation_request, -) -from tests.submission_preparation_auth_helpers import install_submitter_grant -from tests.test_default_pre_submit_execution import _AllowAuthority, _archive, _bytes, _request -from app.modules.artifacts.service import ArtifactStorageNamespaceSpec -from app.modules.authorization.runtime import ( - ActorKind, ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, -) - - -class _CountedProcessor: - def __init__(self, wrapped, calls: list[int], entered=None, release=None): - self._wrapped, self._calls = wrapped, calls - self._entered, self._release = entered, release - - def abort(self): - return self._wrapped.abort() - - async def process(self, reader, workspace): - self._calls.append(1) - if self._entered is not None: - self._entered.set() - await self._release.wait() - return await self._wrapped.process(reader, workspace) - - -class _CountedExecution: - def __init__(self, wrapped, calls, entered=None, release=None): - self._wrapped, self._calls = wrapped, calls - self._entered, self._release = entered, release - - @property - def catalogue_manifest_sha256(self): - return self._wrapped.catalogue_manifest_sha256 - - def build(self, request): - return _CountedProcessor( - self._wrapped.build(request), self._calls, self._entered, self._release, - ) - - -@dataclass -class _Harness: - engine: object - factory: object - request: object - preparation_request: object - preparation: object - inspector: object - catalogue: object - manager: object - evidence_path: str - actor_id: UUID - identity_link_id: UUID - - def workflow(self, session, calls, *, entered=None, release=None, - preparation_authorization=None): - workflow = evidence_workflow( - session=session, - preparation=self.preparation, - inspector=self.inspector, - catalogue=self.catalogue, - materialization_authorization=_AllowAuthority(), - preparation_authorization=( - preparation_authorization - if preparation_authorization is not None - else SimpleNamespace(lock_actor=AsyncMock(), revalidate=AsyncMock()) - ), - ) - materialization = workflow._materialization - materialization._checker_execution = _CountedExecution( - materialization._checker_execution, calls, entered, release, - ) - return workflow - - async def fresh_request(self, data=None): - prepared = await self.preparation.prepare( - _bytes(data if data is not None else _archive(evidence_path=self.evidence_path)), - media_type="application/zip", - ) - inspection = await prepared.inspect(self.inspector) - manifest = build_submission_manifest(inspection) - request = replace( - self.request, - prepared_artifact=prepared, - inspection=inspection, - manifest=manifest, - change_gate=evaluate_submission_change( - commitment=prepared.commitment, - manifest=manifest, - predecessor=None, - predecessor_exists=False, - ), - ) - return request - - async def close(self): - await self.request.prepared_artifact.close() - self.manager.close() - await self.engine.dispose() - - def contributor_authority(self, session): - return PreparedSubmissionBundlePreparationAuthorization( - session, - HumanAuthorizationContext( - actor_profile_id=self.actor_id, - actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, - identity_link_id=self.identity_link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=self.preparation_request.request_id, - correlation_id=self.preparation_request.correlation_id, - ), - ) - - -async def _harness(tmp_path: Path, database_url: str) -> _Harness: - engine = create_async_engine(database_url) - factory = async_sessionmaker(engine, expire_on_commit=False) - namespace = ArtifactStorageNamespaceSpec( - backend="local", adapter="local", provider_profile="test", - namespace_descriptor={"test": "submission-bundle"}, - namespace_fingerprint=canonical_json_hash({"test": "submission-bundle"}), - ) - plan, policy = await approved_pre_submit_fixture(factory, namespace, guide_version="v0.1") - evidence_path = policy.pop("evidence_path") - request, inspector, manager, preparation, catalogue = await _request( - tmp_path, plan=plan, evidence_path=evidence_path, - ) - request = replace(request, packet=replace( - request.packet, - contributor_attestation=( - request.packet.contributor_attestation + " " + " ".join(policy.pop("attestation_terms")) - ), - )) - actor_id, identity_link_id = new_record_id(), new_record_id() - lineage = request.effective_plan.lineage - params = { - "actor": str(actor_id), "subject": str(actor_id), "link": str(identity_link_id), - "project": str(lineage.project_id), "guide": str(lineage.guide_id), - "snapshot": str(lineage.source_snapshot_id), - "snapshot_hash": lineage.source_snapshot_hash, **policy, - "effective_policy": str(lineage.effective_policy_id), - "effective_hash": lineage.effective_policy_hash, - "checker_policy": str(lineage.pre_submit_policy_id), - "checker_hash": lineage.pre_submit_policy_bundle_hash, - "task": str(request.task_id), "assignment": str(request.assignment_id), - } - async with engine.begin() as connection: - await connection.execute(text( - "insert into actor_profiles " - "(id,actor_kind,status,provisioning_method,created_by) values " - "(:actor,'human','active','automatic_first_access','test')" - ), params) - await connection.execute(text( - "insert into actor_identity_links " - "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by,last_verified_at) " - "values (:link,:actor,'flow-test',:subject,'human','active','test',now())" - ), params) - await seed_started_task_for_artifact_test(connection, params) - await install_submitter_grant(connection, params) - preparation_request = submission_preparation_request( - request, actor_profile_id=actor_id, identity_link_id=identity_link_id, - ) - return _Harness( - engine, factory, request, preparation_request, preparation, inspector, - catalogue, manager, evidence_path, actor_id, identity_link_id, - ) - - -async def _reserve(workflow, request, preparation_request): - async with workflow._session.begin(): - return await workflow.reserve(request, preparation_request=preparation_request) - - -@pytest.mark.asyncio -async def test_completed_replay_after_original_scratch_closes( - tmp_path: Path, isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - calls: list[int] = [] - retry = None - try: - async with harness.factory() as session: - workflow = harness.workflow(session, calls) - reservation = await _reserve(workflow, harness.request, harness.preparation_request) - first = await workflow.execute_reserved( - harness.request, reservation, preparation_request=harness.preparation_request, - ) - assert len(calls) == 1 - assert first.pass_capability is not None - original_generation = first.execution.custody.prepared_generation_id - await harness.request.prepared_artifact.close() - retry = await harness.fresh_request() - assert retry.prepared_artifact.generation_id != original_generation - replay = await _reserve(workflow, retry, harness.preparation_request) - assert isinstance(replay, PreSubmitEvidencePersistenceResult) - assert replay.evidence.evidence_set_id == first.evidence.evidence_set_id - assert replay.execution == first.execution - assert replay.execution.custody.prepared_generation_id == original_generation - assert replay.pass_capability is None - assert len(calls) == 1 - changed_packet = replace(retry, packet=replace( - retry.packet, summary="Different work was completed.", - )) - changed_preparation = replace( - harness.preparation_request, summary=changed_packet.packet.summary, - ) - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_attempt_request_conflict"): - await _reserve(workflow, changed_packet, changed_preparation) - assert len(calls) == 1 - await retry.prepared_artifact.close() - retry = await harness.fresh_request( - _archive(extra_path="different.txt", evidence_path=harness.evidence_path) - ) - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_attempt_request_conflict"): - await _reserve(workflow, retry, harness.preparation_request) - assert len(calls) == 1 - finally: - if retry is not None: - await retry.prepared_artifact.close() - await harness.close() - - -@pytest.mark.asyncio -async def test_crash_after_member_before_evidence_commit_cannot_rerun( - tmp_path: Path, isolated_database_env: str, monkeypatch, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - calls: list[int] = [] - original_persist = PreSubmitEvidenceService.persist - - async def crash_after_member(self, request): - raise RuntimeError("simulated crash before evidence commit") - - try: - async with harness.factory() as session: - workflow = harness.workflow(session, calls) - reservation = await _reserve(workflow, harness.request, harness.preparation_request) - monkeypatch.setattr(PreSubmitEvidenceService, "persist", crash_after_member) - with pytest.raises(RuntimeError, match="simulated crash"): - await workflow.execute_reserved( - harness.request, reservation, preparation_request=harness.preparation_request, - ) - monkeypatch.setattr(PreSubmitEvidenceService, "persist", original_persist) - assert len(calls) == 1 - async with harness.factory() as retry_session: - retry_workflow = harness.workflow(retry_session, calls) - with pytest.raises( - PreSubmitEvidenceConflict, match="pre_submit_attempt_outcome_unresolved" - ): - await _reserve( - retry_workflow, harness.request, harness.preparation_request, - ) - assert len(calls) == 1 - attempt = await session.scalar(select(PreSubmitExecutionAttempt)) - assert attempt is not None and attempt.status == "reserved" - assert await session.scalar(select(func.count()).select_from(PreSubmitEvidenceSet)) == 0 - await session.rollback() - finally: - monkeypatch.setattr(PreSubmitEvidenceService, "persist", original_persist) - await harness.close() - - -@pytest.mark.asyncio -async def test_completion_failure_rolls_back_evidence_and_keeps_attempt_reserved( - tmp_path: Path, isolated_database_env: str, monkeypatch, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - calls: list[int] = [] - original_complete = PreSubmitAttemptClaim.complete - - async def fail_completion(self, session, evidence_id): - # The repository has already inserted the evidence and all result rows. - assert await session.get(PreSubmitEvidenceSet, str(evidence_id)) is not None - raise RuntimeError("simulated completion failure") - - try: - async with harness.factory() as session: - workflow = harness.workflow(session, calls) - reservation = await _reserve(workflow, harness.request, harness.preparation_request) - monkeypatch.setattr(PreSubmitAttemptClaim, "complete", fail_completion) - with pytest.raises(RuntimeError, match="simulated completion failure"): - await workflow.execute_reserved( - harness.request, reservation, preparation_request=harness.preparation_request, - ) - monkeypatch.setattr(PreSubmitAttemptClaim, "complete", original_complete) - assert len(calls) == 1 - attempt = await session.scalar(select(PreSubmitExecutionAttempt)) - assert attempt is not None and attempt.status == "reserved" - assert await session.scalar(select(func.count()).select_from(PreSubmitEvidenceSet)) == 0 - await session.rollback() - with pytest.raises( - PreSubmitEvidenceConflict, match="pre_submit_attempt_outcome_unresolved" - ): - await _reserve(workflow, harness.request, harness.preparation_request) - finally: - monkeypatch.setattr(PreSubmitAttemptClaim, "complete", original_complete) - await harness.close() - - -@pytest.mark.asyncio -async def test_independent_sessions_same_key_invoke_members_once( - tmp_path: Path, isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - calls: list[int] = [] - entered, release = asyncio.Event(), asyncio.Event() - executing = competing = None - try: - async with harness.factory() as first_session, harness.factory() as second_session: - first_workflow = harness.workflow(first_session, calls, entered=entered, release=release) - second_workflow = harness.workflow(second_session, calls) - competing_pid = asyncio.get_running_loop().create_future() - task_contexts = second_workflow._task_contexts - - class _ObservedTaskContexts: - async def lock_submission_context(self, request): - pid = await second_session.scalar(text("select pg_backend_pid()")) - competing_pid.set_result(pid) - return await task_contexts.lock_submission_context(request) - - second_workflow._task_contexts = _ObservedTaskContexts() - reservation = await _reserve( - first_workflow, harness.request, harness.preparation_request, - ) - executing = asyncio.create_task(first_workflow.execute_reserved( - harness.request, reservation, preparation_request=harness.preparation_request, - )) - await asyncio.wait_for(entered.wait(), timeout=10) - competing = asyncio.create_task(_reserve( - second_workflow, harness.request, harness.preparation_request, - )) - pid = await asyncio.wait_for(competing_pid, timeout=3) - async with harness.engine.connect() as observer: - wait_type = None - async with asyncio.timeout(3): - while not competing.done(): - await observer.execute(text("select pg_stat_clear_snapshot()")) - wait_type = await observer.scalar(text( - "select wait_event_type from pg_stat_activity where pid=:pid" - ), {"pid": pid}) - if wait_type == "Lock": - break - await asyncio.sleep(0.02) - assert competing.done() or wait_type == "Lock" - release.set() - result = await executing - recovered = (await asyncio.gather(competing, return_exceptions=True))[0] - assert result.pass_capability is not None - if isinstance(recovered, PreSubmitEvidencePersistenceResult): - assert recovered.pass_capability is None - assert recovered.evidence.evidence_set_id == result.evidence.evidence_set_id - else: - assert isinstance(recovered, PreSubmitEvidenceConflict) - assert str(recovered) == "pre_submit_attempt_outcome_unresolved" - assert len(calls) == 1 - finally: - release.set() - await asyncio.gather( - *(task for task in (executing, competing) if task is not None), - return_exceptions=True, - ) - await harness.close() - - -@pytest.mark.asyncio -async def test_uncommitted_reservation_never_issues_a_claim( - tmp_path: Path, isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - calls: list[int] = [] - try: - async with harness.factory() as session: - workflow = harness.workflow(session, calls) - async with session.begin(): - reservation = await workflow.reserve( - harness.request, preparation_request=harness.preparation_request, - ) - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_attempt_not_committed"): - await workflow._attempts.committed_claim(reservation) - await session.rollback() - assert await session.scalar(select(func.count()).select_from(PreSubmitExecutionAttempt)) == 0 - await session.rollback() - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_attempt_not_committed"): - await workflow._attempts.committed_claim(reservation) - assert calls == [] - finally: - await harness.close() - - -@pytest.mark.asyncio -async def test_attempt_database_guards_reject_rewrite_delete_and_orphan_completion( - tmp_path: Path, isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - try: - async with harness.factory() as session: - workflow = harness.workflow(session, []) - reservation = await _reserve(workflow, harness.request, harness.preparation_request) - attempt_id = str(reservation.attempt_id) - statements = ( - ("update pre_submit_execution_attempts set request_digest=:digest where id=:id", - {"digest": "sha256:" + "0" * 64}), - ("update pre_submit_execution_attempts set status='completed',evidence_set_id=:evidence " - "where id=:id", {"evidence": str(new_record_id())}), - ("delete from pre_submit_execution_attempts where id=:id", {}), - ) - for sql, values in statements: - with pytest.raises(DBAPIError): - async with harness.engine.begin() as connection: - await connection.execute(text(sql), {"id": attempt_id, **values}) - async with harness.factory() as session: - attempt = await session.get(PreSubmitExecutionAttempt, attempt_id) - assert attempt is not None and attempt.status == "reserved" - assert attempt.evidence_set_id is None - finally: - await harness.close() - - -@pytest.mark.asyncio -async def test_database_rejects_same_resource_different_packet_evidence_completion( - tmp_path: Path, isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - try: - async with harness.factory() as first_session: - first_workflow = harness.workflow(first_session, []) - first_reservation = await _reserve( - first_workflow, harness.request, harness.preparation_request, - ) - first = await first_workflow.execute_reserved( - harness.request, first_reservation, - preparation_request=harness.preparation_request, - ) - changed_request = replace(harness.request, packet=replace( - harness.request.packet, summary="A different packet for the same work artifact.", - )) - changed_preparation = replace( - harness.preparation_request, - idempotency_key=new_record_id(), - summary=changed_request.packet.summary, - ) - async with harness.factory() as second_session: - second_workflow = harness.workflow(second_session, []) - second_reservation = await _reserve( - second_workflow, changed_request, changed_preparation, - ) - assert first_reservation.request_digest != second_reservation.request_digest - new_evidence = str(new_record_id()) - source_evidence = str(first.evidence.evidence_set_id) - async with harness.factory() as session: - original_evidence = await session.get(PreSubmitEvidenceSet, source_evidence) - second_attempt = await session.get( - PreSubmitExecutionAttempt, str(second_reservation.attempt_id), - ) - assert original_evidence is not None and second_attempt is not None - assert original_evidence.packet_sha256 == canonical_json_hash( - asdict(harness.request.packet) - ) - assert original_evidence.packet_sha256 != second_attempt.request_json["packet_sha256"] - columns = tuple(column.name for column in PreSubmitEvidenceSet.__table__.columns) - replacements = { - "id": ":new_evidence", - "operation_identity": ":new_operation", - "attempt_id": ":new_attempt", - "created_at": "transaction_timestamp()", - # The request digest is valid for the second attempt. Only the - # evidence's independently captured packet hash remains original. - "attempt_request_digest": ":correct_request_digest", - } - copy_sql = ( - f"insert into pre_submit_evidence_sets ({', '.join(columns)}) " - f"select {', '.join(replacements.get(column, column) for column in columns)} " - "from pre_submit_evidence_sets where id=:source_evidence" - ) - result_columns = tuple(column.name for column in PreSubmitEvidenceResult.__table__.columns) - result_replacements = { - "id": ":new_result", - "evidence_set_id": ":new_evidence", - "created_at": "transaction_timestamp()", - } - copy_results_sql = ( - f"insert into pre_submit_evidence_results ({', '.join(result_columns)}) " - f"select {', '.join(result_replacements.get(column, column) for column in result_columns)} " - "from pre_submit_evidence_results where evidence_set_id=:source_evidence " - "and result_order=:result_order" - ) - with pytest.raises(DBAPIError, match="pre-submit evidence packet mismatch"): - async with harness.engine.begin() as connection: - await connection.execute(text(copy_sql), { - "new_evidence": new_evidence, - "new_operation": canonical_json_hash({"counterexample": new_evidence}), - "new_attempt": str(second_reservation.attempt_id), - "correct_request_digest": second_reservation.request_digest, - "source_evidence": source_evidence, - }) - copied_results = 0 - for result_order in range(len(first.execution.entries)): - copied = await connection.execute(text(copy_results_sql), { - "new_result": str(new_record_id()), - "new_evidence": new_evidence, - "source_evidence": source_evidence, - "result_order": result_order, - }) - copied_results += copied.rowcount - assert copied_results == len(first.execution.entries) - await connection.execute(text( - "update pre_submit_execution_attempts set status='completed'," - "evidence_set_id=:evidence where id=:attempt" - ), {"evidence": new_evidence, "attempt": str(second_reservation.attempt_id)}) - async with harness.factory() as session: - attempt = await session.get( - PreSubmitExecutionAttempt, str(second_reservation.attempt_id), - ) - assert attempt is not None and attempt.status == "reserved" - assert await session.get(PreSubmitEvidenceSet, new_evidence) is None - assert await session.get(PreSubmitEvidenceSet, source_evidence) is not None - finally: - await harness.close() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("phase", ("before_execution", "completed_replay")) -async def test_revoked_contributor_cannot_execute_or_replay_after_reservation( - tmp_path: Path, isolated_database_env: str, phase: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - calls: list[int] = [] - retry = None - try: - async with harness.factory() as session: - authority = harness.contributor_authority(session) - await authority.preflight(request=harness.preparation_request) - async with session.begin(): - await authority.revalidate( - request=harness.preparation_request, - project_id=harness.request.effective_plan.lineage.project_id, - ) - workflow = harness.workflow( - session, calls, preparation_authorization=authority, - ) - reservation = await _reserve( - workflow, harness.request, harness.preparation_request, - ) - if phase == "completed_replay": - completed = await workflow.execute_reserved( - harness.request, reservation, - preparation_request=harness.preparation_request, - ) - assert completed.pass_capability is not None - assert len(calls) == 1 - await harness.request.prepared_artifact.close() - retry = await harness.fresh_request() - else: - assert calls == [] - async with harness.engine.begin() as connection: - await connection.execute(text( - "update actor_identity_links set status='revoked'," - "revoked_by='test',revoked_at=now(),revoked_reason='test revocation' " - "where id=:link" - ), {"link": str(harness.identity_link_id)}) - with pytest.raises(ArtifactAuthorityDeniedError): - if phase == "before_execution": - await workflow.execute_reserved( - harness.request, reservation, - preparation_request=harness.preparation_request, - ) - else: - await _reserve(workflow, retry, harness.preparation_request) - assert len(calls) == (0 if phase == "before_execution" else 1) - finally: - if retry is not None: - await retry.prepared_artifact.close() - await harness.close() diff --git a/backend/tests/test_pre_submit_attempt_schema.py b/backend/tests/test_pre_submit_attempt_schema.py deleted file mode 100644 index a14e58c32..000000000 --- a/backend/tests/test_pre_submit_attempt_schema.py +++ /dev/null @@ -1,422 +0,0 @@ -"""Current pre-submit result ordering and metadata schema proof.""" - - - -import pytest -from sqlalchemy import text -from sqlalchemy.exc import DBAPIError - -from app.core.identifiers import new_record_id -from tests.test_pre_submit_attempt_recovery import _harness, _reserve - - -pytestmark = pytest.mark.postgres_schema_contract - - -@pytest.mark.asyncio -async def test_new_result_rows_require_valid_order_and_bounded_metadata( - tmp_path, - isolated_database_env: str, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - try: - async with harness.factory() as session: - workflow = harness.workflow(session, []) - reservation = await _reserve(workflow, harness.request, harness.preparation_request) - completed = await workflow.execute_reserved( - harness.request, - reservation, - preparation_request=harness.preparation_request, - ) - retained_id = str(completed.evidence.evidence_set_id) - new_id = str(new_record_id()) - attempt_id = str(new_record_id()) - packet_sha256 = "sha256:" + "d" * 64 - async with harness.engine.connect() as connection: - transaction = await connection.begin() - try: - await connection.execute( - text( - "insert into pre_submit_execution_attempts (" - "id,idempotency_key,actor_profile_id,identity_link_id,task_id," - "assignment_id,prepared_generation_id,claim_nonce,request_json," - "request_digest,status) select :attempt,:key,e.actor_profile_id," - "e.identity_link_id,e.task_id,e.assignment_id,e.prepared_generation_id," - ":nonce,(to_jsonb(e)||jsonb_build_object('packet_sha256'," - "cast(:packet_sha256 as text)))::json,'sha256:'||encode(sha256(" - "convert_to(project_guide_projection_canonical_json(to_jsonb(e)||" - "jsonb_build_object('packet_sha256',cast(:packet_sha256 as text)))," - "'UTF8')),'hex'),'reserved' from pre_submit_evidence_sets e " - "where e.id=:retained" - ), - { - "attempt": attempt_id, - "key": str(new_record_id()), - "nonce": str(new_record_id()), - "packet_sha256": packet_sha256, - "retained": retained_id, - }, - ) - await connection.execute( - text( - "insert into pre_submit_evidence_sets select " - "(jsonb_populate_record(null::pre_submit_evidence_sets,to_jsonb(e)||" - "jsonb_build_object('id',cast(:new_id as text),'operation_identity'," - "'sha256:'||repeat('c',64),'packet_sha256',cast(:packet_sha256 as text)," - "'attempt_id',cast(:attempt as text),'attempt_request_digest'," - "a.request_digest,'created_at',transaction_timestamp()))).* " - "from pre_submit_evidence_sets e join pre_submit_execution_attempts a " - "on a.id=:attempt where e.id=:retained" - ), - { - "new_id": new_id, - "retained": retained_id, - "packet_sha256": packet_sha256, - "attempt": attempt_id, - }, - ) - await connection.execute( - text( - "update pre_submit_execution_attempts set status='completed'," - "evidence_set_id=:evidence where id=:attempt" - ), - {"evidence": new_id, "attempt": attempt_id}, - ) - await connection.execute(text("set constraints all immediate")) - insert_result = text( - "insert into pre_submit_evidence_results (id,evidence_set_id," - "result_order,schema_version,dispatch_authority,definition_id," - "definition_version,public_name,source,phase,classification,severity," - "status,message_code,effective_plan_sha256,locked_policy_sha256," - "checker_order,metadata_json) values (:id,:parent,0,'v1'," - "'schema-test','test.check','v1','Test check','test','custody'," - "'mandatory_integrity','blocking','passed','test.passed',:digest," - ":digest,:checker_order,cast(:metadata as json))" - ) - parameters = {"parent": new_id, "digest": "sha256:" + "a" * 64} - invalid = ( - (None, "[]", "pre-submit result reconstruction fields required"), - (-1, "[]", "pre-submit result reconstruction fields required"), - (0, None, "pre-submit result reconstruction fields required"), - (0, '{"entry_count":1}', "pre-submit result reconstruction fields required"), - (0, '[["entry_count",-1]]', "pre-submit result metadata invalid"), - ( - 0, - '[["entry_count",1],["entry_count",2]]', - "pre-submit result metadata invalid", - ), - ) - for checker_order, metadata, expected in invalid: - with pytest.raises(DBAPIError, match=expected): - async with connection.begin_nested(): - await connection.execute( - insert_result, - { - **parameters, - "id": str(new_record_id()), - "checker_order": checker_order, - "metadata": metadata, - }, - ) - await connection.execute( - insert_result, - { - **parameters, - "id": str(new_record_id()), - "checker_order": 0, - "metadata": '[["entry_count",1],["finding_count",0]]', - }, - ) - assert ( - await connection.scalar( - text( - "select count(*) from pre_submit_evidence_results " - "where evidence_set_id=:id and checker_order=0" - ), - {"id": new_id}, - ) - == 1 - ) - finally: - await transaction.rollback() - finally: - await harness.close() - - -async def test_manifest_metadata_is_hash_bound_on_insert_and_immutable( - tmp_path, isolated_database_env, -): - """Real inspected evidence is the valid control for direct storage rejections.""" - import json - from copy import deepcopy - from app.core.hashing import canonical_json_hash - - harness = await _harness(tmp_path, isolated_database_env) - try: - async with harness.factory() as session: - workflow = harness.workflow(session, []) - reservation = await _reserve(workflow, harness.request, harness.preparation_request) - completed = await workflow.execute_reserved( - harness.request, reservation, preparation_request=harness.preparation_request, - ) - evidence_id = str(completed.evidence.evidence_set_id) - async with harness.factory() as session: - body, digest = (await session.execute(text( - "select semantic_manifest_body,semantic_manifest_sha256 " - "from pre_submit_evidence_sets where id=:id" - ), {"id": evidence_id})).one() - assert canonical_json_hash(body) == digest - from tests.test_submission_manifest import _archive, _manifest - unicode_manifest = _manifest(_archive([("café/α.txt", b"verified")])) - malformed = deepcopy(body) - malformed["entries"][0]["content"] = "not metadata" - async with harness.factory() as session, session.begin(): - for candidate, commitment, valid in ( - (body, digest, True), (unicode_manifest.as_dict(), unicode_manifest.sha256, True), - (body, "sha256:" + "f" * 64, False), - (None, digest, False), (malformed, canonical_json_hash(malformed), False), - ): - assert await session.scalar(text( - "select public.submission_manifest_metadata_valid(cast(:body as jsonb),:digest)" - ), {"body": json.dumps(candidate), "digest": commitment}) is valid - for path in ("../outside.txt", "/absolute.txt", "dir\\file.txt", "a//b", "a/./b", "a./b", "drive:c/file", "cafe\u0301.txt", "x" * 4097): - candidate = deepcopy(unicode_manifest.as_dict()) - candidate["entries"] = [candidate["entries"][-1]] - candidate["entries"][0]["normalized_path"] = path - assert not await session.scalar(text( - "select public.submission_manifest_metadata_valid(cast(:body as jsonb),:digest)" - ), {"body": json.dumps(candidate), "digest": canonical_json_hash(candidate)}) - for candidate, commitment in ((None, digest), (malformed, digest), (body, "sha256:" + "f" * 64)): - with pytest.raises(DBAPIError, match="pre-submit manifest metadata invalid"): - async with session.begin_nested(): - await session.execute(text( - "insert into pre_submit_evidence_sets select " - "(jsonb_populate_record(null::pre_submit_evidence_sets,to_jsonb(e)||" - "jsonb_build_object('semantic_manifest_body',cast(:body as jsonb)," - "'semantic_manifest_sha256',cast(:digest as text),'created_at',transaction_timestamp()))).* " - "from pre_submit_evidence_sets e where e.id=:id" - ), {"body": json.dumps(candidate), "digest": commitment, "id": evidence_id}) - with pytest.raises(DBAPIError, match="immutable"): - async with session.begin_nested(): - await session.execute(text( - "update pre_submit_evidence_sets set semantic_manifest_body=null where id=:id" - ), {"id": evidence_id}) - assert await session.scalar(text( - "select semantic_manifest_body from pre_submit_evidence_sets where id=:id" - ), {"id": evidence_id}) == body - finally: - await harness.close() - - -async def test_manifest_upgrade_preserves_old_evidence_without_inventing_metadata( - tmp_path, isolated_database_env, migration_lock, -): - import asyncio - import asyncpg - from alembic import command - from app.db import session as db_session - from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidenceConflict - from tests.migration_fixtures import ( - _config, add_current_art_seed_column, restore_predecessor_evidence_schema, - ) - - with migration_lock(): - await db_session.dispose_engine() - connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) - try: - await connection.execute("drop schema public cascade; create schema public") - finally: - await connection.close() - await asyncio.to_thread(command.upgrade, _config(), "0020_review_admission_lock_order") - original_columns = await add_current_art_seed_column(isolated_database_env) - harness = await _harness(tmp_path, isolated_database_env) - try: - calls = [] - async with harness.factory() as session: - workflow = harness.workflow(session, calls) - reservation = await _reserve(workflow, harness.request, harness.preparation_request) - completed = await workflow.execute_reserved( - harness.request, reservation, preparation_request=harness.preparation_request, - ) - evidence_id = str(completed.evidence.evidence_set_id) - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) - async with harness.factory() as session: - before = await session.scalar(text( - "select to_jsonb(e) from pre_submit_evidence_sets e where id=:id" - ), {"id": evidence_id}) - await asyncio.to_thread(command.upgrade, _config(), "head") - async with harness.factory() as session: - after = await session.scalar(text( - "select to_jsonb(e) from pre_submit_evidence_sets e where id=:id" - ), {"id": evidence_id}) - assert set(after) == set(before) | { - "semantic_manifest_body", "checker_remediation_manifest_id", - } - assert after.pop("semantic_manifest_body") is None - assert after.pop("checker_remediation_manifest_id") is None - assert after == before - async with harness.factory() as session: - workflow = harness.workflow(session, calls) - with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_attempt_manifest_invalid"): - await _reserve(workflow, harness.request, harness.preparation_request) - assert calls == [1] - finally: - await harness.close() - - -async def test_upgraded_consumed_admission_without_metadata_is_unavailable( - tmp_path, isolated_database_env, migration_lock, -): - """Real retained admission recovery denies without rewriting its consumed facts.""" - import asyncio - import asyncpg - from alembic import command - from app.db import session as db_session - from app.modules.artifacts.api import SubmissionAdmissionConsumptionError - from app.modules.checkers.api import SubmissionPacketView - from app.modules.artifacts.submission_bindings import SubmissionAdmissionConsumptionService - from tests.migration_fixtures import ( - _config, add_current_art_seed_column, restore_predecessor_evidence_schema, - ) - from tests.historical_submission_fixtures import historical_material_fixture - from app.modules.artifacts.api.submission_admission import ConsumedSubmissionAdmissionRequest - from tests.test_artifact_bindings import _Allow - - with migration_lock(): - await db_session.dispose_engine() - connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) - try: - await connection.execute("drop schema public cascade; create schema public") - finally: - await connection.close() - await asyncio.to_thread(command.upgrade, _config(), "0020_review_admission_lock_order") - original_columns = await add_current_art_seed_column(isolated_database_env) - async with historical_material_fixture(tmp_path, isolated_database_env) as h: - request = ConsumedSubmissionAdmissionRequest( - admission_id=h.created.admission_id, project_id=h.request.project_id, - task_id=h.request.task_id, assignment_id=h.request.assignment_id, - contributor_id=h.facts.contributor_id, submission_id=h.created.submission_id, - submission_version=h.created.submission_version, - packet_sha256=SubmissionPacketView(h.creation_request.summary, h.creation_request.contributor_attestation).sha256, - ) - await restore_predecessor_evidence_schema(isolated_database_env, original_columns) - async with h.factory() as session: - before = await session.scalar(text( - "select to_jsonb(a) from submission_bundle_admissions a where id=:id" - ), {"id": str(request.admission_id)}) - bindings = await session.scalar(text("select count(*) from artifact_bindings")) - await asyncio.to_thread(command.upgrade, _config(), "head") - authority = _Allow() - async with h.factory() as session, session.begin(): - with pytest.raises(SubmissionAdmissionConsumptionError, match="submission_bundle_admission_unavailable"): - await SubmissionAdmissionConsumptionService(session, authority).read_consumed(request) - after = await session.scalar(text( - "select to_jsonb(a) from submission_bundle_admissions a where id=:id" - ), {"id": str(request.admission_id)}) - assert set(after) == set(before) | {"checker_remediation_manifest_id"} - assert after.pop("checker_remediation_manifest_id") is None - assert after == before - column = (await session.execute(text( - "select data_type,is_nullable from information_schema.columns " - "where table_schema='public' " - "and table_name='submission_bundle_admissions' " - "and column_name='checker_remediation_manifest_id'" - ))).one() - assert tuple(column) == ("uuid", "YES") - assert await session.scalar(text("select count(*) from artifact_bindings")) == bindings - authority.consume.assert_not_awaited() - assert h.store.opens == [] - - -async def _create_manifest_insert_probe(connection): - """Use the real column types and INSERT guard without unrelated parent constraints.""" - await connection.execute( - 'CREATE TEMP TABLE manifest_insert_probe AS SELECT semantic_manifest_body, ' - 'semantic_manifest_sha256 FROM public.pre_submit_evidence_sets WITH NO DATA' - ) - await connection.execute( - 'CREATE TRIGGER manifest_probe BEFORE INSERT ON manifest_insert_probe ' - 'FOR EACH ROW EXECUTE FUNCTION public.guard_submission_manifest_metadata()' - ) - - -async def test_native_json_insert_recovers_the_validated_numeric_representation(isolated_database_env): - import asyncpg - import json - from app.modules.artifacts.submission_manifest import SubmissionManifest - from app.core.hashing import canonical_json_hash - from tests.test_submission_manifest import _archive, _manifest - - connection = await asyncpg.connect(isolated_database_env.replace('+asyncpg', '')) - try: - await _create_manifest_insert_probe(connection) - for count, literal in ((1, '1'), (1, '1e0'), (10, '1e1')): - manifest = _manifest(_archive([('a', b'x' * count)])) - native_json = json.dumps(manifest.as_dict()).replace(f'"byte_count": {count}', f'"byte_count": {literal}') - assert f'"byte_count": {literal}' in native_json - if 'e' in literal: - assert type(json.loads(native_json)['entries'][0]['byte_count']) is float - stored = await connection.fetchval( - 'INSERT INTO manifest_insert_probe VALUES ($1::json,$2) RETURNING semantic_manifest_body', - native_json, manifest.sha256, - ) - recovered = json.loads(stored) - assert type(recovered['entries'][0]['byte_count']) is int - assert SubmissionManifest.from_dict(recovered, sha256=manifest.sha256) == manifest - for literal in ('1.0', '1.5'): - manifest = _manifest(_archive([('a', b'x')])) - native_json = json.dumps(manifest.as_dict()).replace('"byte_count": 1', f'"byte_count": {literal}') - with pytest.raises(asyncpg.CheckViolationError, match='pre-submit manifest metadata invalid'): - await connection.execute('INSERT INTO manifest_insert_probe VALUES ($1::json,$2)', native_json, canonical_json_hash(json.loads(native_json))) - finally: - await connection.close() - - -async def test_manifest_insert_guard_rejects_hash_consistent_inventory_collisions(isolated_database_env): - import asyncpg - import json - from app.core.hashing import canonical_json_hash - from tests.test_submission_manifest import _archive, _manifest - - connection = await asyncpg.connect(isolated_database_env.replace('+asyncpg', '')) - try: - await _create_manifest_insert_probe(connection) - valid = _manifest(_archive([('café/a', b'x'), ('cafe', b'x')])) - await connection.execute('INSERT INTO manifest_insert_probe VALUES ($1::json,$2)', json.dumps(valid.as_dict()), valid.sha256) - template = _manifest(_archive([('safe', b'x')])).as_dict() - entry = template['entries'][0] - for paths in (('A', 'a'), ('dir', 'dir/x'), ('DIR/x', 'dir'), ('STRASSE', 'Straße'), ('Σ', 'ς'), ('İ', 'i\u0307')): - body = dict(template, entries=sorted([dict(entry, normalized_path=path) for path in paths], key=lambda item: item['normalized_path'])) - with pytest.raises(asyncpg.CheckViolationError, match='pre-submit manifest metadata invalid'): - await connection.execute('INSERT INTO manifest_insert_probe VALUES ($1::json,$2)', json.dumps(body), canonical_json_hash(body)) - assert await connection.fetchval('SELECT count(*) FROM manifest_insert_probe') == 1 - finally: - await connection.close() - - -async def test_database_casefold_matches_frozen_unicode_15(isolated_database_env): - import asyncpg - import hashlib - import importlib.util - import json - from pathlib import Path - import unicodedata - - spec = importlib.util.spec_from_file_location('manifest_migration', Path(__file__).parents[1] / 'alembic/versions/0021_submission_manifest.py') - migration = importlib.util.module_from_spec(spec) - spec.loader.exec_module(migration) - mapping = json.loads(migration._UNICODE_15_CASEFOLD_JSON) - assert unicodedata.unidata_version == '15.0.0' - assert len(mapping) == 1530 - assert hashlib.sha256(migration._UNICODE_15_CASEFOLD_JSON.encode()).hexdigest() == 'c0d6e0ca212805674e7443c1212d732e11fb9faf16d92326aa1a9536caadbf3a' - expected = {chr(i): chr(i).casefold() for i in range(0x110000) if chr(i).casefold() != chr(i)} - assert mapping == expected - connection = await asyncpg.connect(isolated_database_env.replace('+asyncpg', '')) - try: - values = list(mapping) + ['', 'café/cafe', 'dir/file', '123', '中', '🙂', 'Straße/İΣ'] - observed = await connection.fetch( - 'SELECT value,public.submission_archive_casefold(value) AS folded FROM unnest($1::text[]) AS values(value)', values, - ) - assert [(row['value'], row['folded']) for row in observed] == [(value, value.casefold()) for value in values] - finally: - await connection.close() diff --git a/backend/tests/test_pre_submit_evidence_relock.py b/backend/tests/test_pre_submit_evidence_relock.py index 7ba53ee84..6dc630659 100644 --- a/backend/tests/test_pre_submit_evidence_relock.py +++ b/backend/tests/test_pre_submit_evidence_relock.py @@ -1,73 +1,76 @@ -"""Focused proof for post-byte pre-submit evidence relocking.""" +"""Post-byte TASK drift refuses selected execution and durable admission.""" +from contextlib import asynccontextmanager from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest -from app.modules.artifacts import pre_submit_evidence -from app.modules.artifacts.pre_submit_evidence import ( - PreSubmitEvidenceConflict, - PreSubmitEvidenceService, +from app.core.identifiers import new_record_id +from app.modules.artifacts.api import ( + SubmissionBundlePreparationRejected, SubmissionBundlePreparationRequest, ) +from app.modules.artifacts.submission_admission import PreparedSubmissionBundlePreparationCommand +from app.modules.authorization.api import ActorIdentityFacts, ActorKind from app.modules.tasks.api import TaskSubmissionContextUnavailable -from tests.test_submission_manifest import _archive, _manifest @pytest.mark.asyncio -async def test_stale_task_relock_denies_before_evidence_or_pass_capability( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A post-byte TASK drift cannot persist evidence or mint continuation.""" - transaction = SimpleNamespace(is_active=True) - session = SimpleNamespace( - sync_session=SimpleNamespace(get_transaction=lambda: transaction), - in_nested_transaction=lambda: False, +async def test_stale_task_relock_denies_before_selected_execution_or_admission(): + actor = ActorIdentityFacts(new_record_id(), new_record_id(), ActorKind.HUMAN) + references = SimpleNamespace( + project_id=new_record_id(), guide_version="v1", + source_snapshot_id=new_record_id(), source_snapshot_hash="sha256:" + "1" * 64, + effective_policy_id=new_record_id(), effective_policy_hash="sha256:" + "2" * 64, + pre_submit_policy_id=new_record_id(), pre_submit_policy_bundle_hash="sha256:" + "3" * 64, ) - task_contexts = SimpleNamespace( - lock_submission_context=AsyncMock( - side_effect=TaskSubmissionContextUnavailable( - "task_submission_context_invalid" - ) - ) - ) - project_contexts = SimpleNamespace(lock_locked_policy_context=AsyncMock()) - service = PreSubmitEvidenceService( - session, # type: ignore[arg-type] - task_contexts=task_contexts, - project_contexts=project_contexts, - ) - service._repository.persist = AsyncMock() # type: ignore[method-assign] - monkeypatch.setattr(pre_submit_evidence, "_validate_execution", lambda *_: None) - manifest = _manifest(_archive([("answer.txt", b"verified")])) - custody = SimpleNamespace( - storage_scheme="s3", - prepared_generation_id="generation", - archive_sha256="archive", - archive_byte_count=1, - semantic_manifest_sha256=manifest.sha256, - ) - request = SimpleNamespace( - manifest=manifest, - plan=object(), - execution=SimpleNamespace(custody=custody), - prepared_generation_id="generation", - archive_sha256="archive", - archive_byte_count=1, - semantic_manifest_sha256=manifest.sha256, - task_id="task", - assignment_id="assignment", - actor_profile_id="actor", - predecessor_submission_id=None, + task = SimpleNamespace(locked_project_context=references, predecessor=None, + checker_remediation_manifest_id=None) + tasks = SimpleNamespace(lock_submission_context=AsyncMock(side_effect=[ + task, TaskSubmissionContextUnavailable("task_submission_context_invalid"), + ])) + projects = SimpleNamespace(lock_locked_policy_context=AsyncMock(return_value=object())) + authority = SimpleNamespace(preflight=AsyncMock(), lock_actor=AsyncMock(), + revalidate=AsyncMock(), close=Mock()) + prepared = SimpleNamespace(close=AsyncMock()) + coordinator = SimpleNamespace(read_current=AsyncMock(), reserve=AsyncMock()) + executor = SimpleNamespace(execute=AsyncMock()) + durable = SimpleNamespace(admit_in_transaction=AsyncMock(), publish_after_commit=AsyncMock()) + runtime = SimpleNamespace( + selected_coordinator=coordinator, selected_execution=executor, storage_scheme="s3", + preparation=SimpleNamespace(prepare=AsyncMock(return_value=prepared)), durable_put=durable, ) - with pytest.raises( - PreSubmitEvidenceConflict, - match="pre_submit_locked_context_changed", - ): - await service.persist(request) # type: ignore[arg-type] + @asynccontextmanager + async def root(): + yield - task_contexts.lock_submission_context.assert_awaited_once() - project_contexts.lock_locked_policy_context.assert_not_awaited() - service._repository.persist.assert_not_awaited() # type: ignore[union-attr] - assert service._live_pass_bindings == set() + @asynccontextmanager + async def runtime_factory(): + yield runtime + + command = PreparedSubmissionBundlePreparationCommand( + session=SimpleNamespace(begin=root), authority=authority, + task_contexts=tasks, project_contexts=projects, runtime_factory=runtime_factory, + ) + request = SubmissionBundlePreparationRequest( + actor=actor, request_id=new_record_id(), correlation_id=new_record_id(), + task_id=new_record_id(), assignment_id=new_record_id(), predecessor_submission_id=None, + idempotency_key=new_record_id(), summary="", contributor_attestation="", + media_type="application/zip", byte_source=object(), + ) + with pytest.raises(SubmissionBundlePreparationRejected, + match="^submission_bundle_preparation_context_changed$"): + await command.prepare(request) + assert tasks.lock_submission_context.await_count == 2 + runtime.preparation.prepare.assert_awaited_once_with(request.byte_source, media_type="application/zip") + projects.lock_locked_policy_context.assert_awaited_once() + authority.lock_actor.assert_awaited_once() + authority.revalidate.assert_awaited_once() + coordinator.read_current.assert_not_awaited() + coordinator.reserve.assert_not_awaited() + executor.execute.assert_not_awaited() + durable.admit_in_transaction.assert_not_awaited() + durable.publish_after_commit.assert_not_awaited() + prepared.close.assert_awaited_once() + authority.close.assert_called_once_with() diff --git a/backend/tests/test_pre_submit_related_lock_order.py b/backend/tests/test_pre_submit_related_lock_order.py deleted file mode 100644 index 0b9ab6289..000000000 --- a/backend/tests/test_pre_submit_related_lock_order.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Real PostgreSQL races between pre-submit, grant revocation, and TASK reads.""" - -from __future__ import annotations - -from app.adapters.artifacts import task_guide_documents_port -from app.adapters.contributions import locked_compensation_terms_port -from app.core.config import get_settings - -from app.adapters.tasks import task_service - -import asyncio -from datetime import UTC, datetime -from pathlib import Path -from uuid import UUID -from app.core.identifiers import new_record_id - -import pytest -from sqlalchemy import func, select, text -from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from app.adapters.audit import task_transition_audit -from app.modules.actors.models import ActorIdentityLink, ActorProfile -from app.modules.artifacts.authorization import PreparedPreSubmitMaterializationAuthorization -from app.modules.artifacts.models import PreSubmitEvidenceSet, PreSubmitExecutionAttempt -from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError -from app.modules.actors.service import ResolvedActor -from app.modules.authorization import router as authorization_router -from app.modules.authorization.kernel import AuthorizationService -from app.modules.authorization.models import ProjectRoleGrant -from app.modules.authorization.prepared import PreparedAuthorizationService -from app.modules.authorization.project_role_schemas import ProjectRoleGrantRevokeBody -from app.modules.authorization.repository import AdminAuthorizationRepository -from app.modules.authorization.runtime import ( - ActorKind, ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, -) -from app.modules.authorization.task_authorization import PreparedTaskAuthorization -from app.modules.tasks.authorized_commands import AuthorizedTaskCommands -from project_create_fixtures import grant_fixture_admin_role -from tests.authorization.test_pre_submit_attempt_authority import _seed_materializer -from tests.test_pre_submit_attempt_authority_integration import _reserve_with_real_materializer -from tests.test_pre_submit_attempt_lock_order import _wait_for_project_block -from tests.auth_concurrency_support import wait_for_named_database_lock -from tests.test_pre_submit_attempt_recovery import _harness - - -class _PauseBeforeProject: - """Hold ART after its TASK and actor/link fence, before the PROJECT fence.""" - - def __init__(self, repository, entered: asyncio.Event, release: asyncio.Event): - self.repository, self.entered, self.release = repository, entered, release - - async def lock_locked_policy_context(self, request): - self.entered.set() - await self.release.wait() - return await self.repository.lock_locked_policy_context(request) - - -async def _assert_row_locked(engine, table: str, row_id: UUID) -> None: - """NOWAIT proves the first operation owns the contributor fence.""" - assert table in {"actor_profiles", "actor_identity_links"} - async with engine.connect() as observer: - try: - await observer.execute( - text(f"select id from {table} where id=:id for update nowait"), - {"id": str(row_id)}, - ) - except DBAPIError as exc: - assert getattr(exc.orig, "sqlstate", None) == "55P03", exc - else: - pytest.fail(f"First operation did not lock {table} before PROJECT") - - -async def _ready_workflow(harness, session, calls): - contributor = harness.contributor_authority(session) - await contributor.preflight(request=harness.preparation_request) - async with session.begin(): - await contributor.revalidate( - request=harness.preparation_request, - project_id=harness.request.effective_plan.lineage.project_id, - ) - materializer = PreparedPreSubmitMaterializationAuthorization( - session, - request_id=harness.preparation_request.request_id, - correlation_id=harness.preparation_request.correlation_id, - ) - workflow = harness.workflow( - session, calls, preparation_authorization=contributor, - ) - workflow._materialization._authorization = materializer - return workflow, materializer - - -async def _manager_runtime(harness, session): - """Use the canonical AUTH PREP service and a real project-manager grant.""" - manager_id, link_id = new_record_id(), new_record_id() - session.add_all([ - ActorProfile( - id=str(manager_id), actor_kind="human", status="active", - provisioning_method="automatic_first_access", created_by="test", - ), - ActorIdentityLink( - id=str(link_id), actor_profile_id=str(manager_id), issuer="flow-test", - subject=f"revoke-lock-{manager_id}", subject_kind="human", - status="active", linked_by="test", last_verified_at=datetime.now(UTC), - ), - ]) - await session.flush() - await grant_fixture_admin_role( - session, manager_id, scope="project", - project_id=harness.request.effective_plan.lineage.project_id, - ) - await session.commit() - context = HumanAuthorizationContext( - actor_profile_id=manager_id, actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, identity_link_id=link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=new_record_id(), correlation_id=new_record_id(), - ) - repository = AdminAuthorizationRepository(session) - authority = AuthorizationService(session, context, admin_repository=repository) - prepared = PreparedAuthorizationService(session, context, authority, repository) - profile = await session.get(ActorProfile, str(manager_id)) - link = await session.get(ActorIdentityLink, str(link_id)) - assert profile is not None and link is not None - await session.commit() - return prepared, ResolvedActor(profile, link) - - -async def _submitter_grant(harness): - """Read the exact active grant whose revocation races with ART.""" - async with harness.factory() as session: - grant_id = await session.scalar(select(ProjectRoleGrant.id).where( - ProjectRoleGrant.project_id == str(harness.request.effective_plan.lineage.project_id), - ProjectRoleGrant.actor_profile_id == str(harness.actor_id), - ProjectRoleGrant.role == "submitter", ProjectRoleGrant.status == "active", - )) - assert grant_id is not None - return grant_id - - -def _named_factory(database_url, name): - """Give each competing owner an independently observable database session.""" - engine = create_async_engine(database_url, connect_args={"server_settings": {"application_name": name}}) - return engine, async_sessionmaker(engine, expire_on_commit=False) - - -async def _wait_for_named_owner(harness, database_url, waiter, blocker): - """Bind the actual waiter to the independently observed blocking backend.""" - async with harness.engine.connect() as observer: - pid = await observer.scalar(text( - "select pid from pg_stat_activity where application_name=:name" - ), {"name": blocker}) - assert pid is not None - await wait_for_named_database_lock(database_url, waiter, expected_blocker_pid=pid) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("ordering", ["materialization_first", "revoke_first"]) -async def test_revocation_fences_art_materialization_and_evidence_persistence( - tmp_path: Path, isolated_database_env: str, monkeypatch, ordering: str, -) -> None: - """Revocation fences both checking and the later evidence-persistence transaction.""" - harness = await _harness(tmp_path, isolated_database_env) - art_engine = revoke_engine = None - art_task = revoke_task = None - release_art, release_revoke = asyncio.Event(), asyncio.Event() - try: - await _seed_materializer(harness.factory) - project_id = harness.request.effective_plan.lineage.project_id - grant_id = await _submitter_grant(harness) - revoke_name = f"pol07a_revoke_{new_record_id().hex[:12]}" - art_name = f"pol07a_revoke_art_{new_record_id().hex[:12]}" - art_engine, art_factory = _named_factory(isolated_database_env, art_name) - revoke_engine, revoke_factory = _named_factory(isolated_database_env, revoke_name) - art_before_project, revoke_has_project = asyncio.Event(), asyncio.Event() - async with art_factory() as art_session, revoke_factory() as revoke_session: - calls: list[int] = [] - workflow, materializer = await _ready_workflow(harness, art_session, calls) - prepared_revoke, resolved_manager = await _manager_runtime(harness, revoke_session) - reservation = await _reserve_with_real_materializer( - workflow, harness.request, harness.preparation_request, - ) - workflow._project_contexts = _PauseBeforeProject( - workflow._project_contexts, art_before_project, release_art, - ) - original_lock_project = AdminAuthorizationRepository.lock_project - revoke_pid: int | None = None - - async def pause_revoke_project(repository, locked_project_id): - nonlocal revoke_pid - project = await original_lock_project(repository, locked_project_id) - if repository._session is revoke_session: - revoke_pid = int(await revoke_session.scalar(text("select pg_backend_pid()"))) - revoke_has_project.set() - await release_revoke.wait() - return project - - monkeypatch.setattr( - AdminAuthorizationRepository, "lock_project", pause_revoke_project, - ) - try: - async def execute_art(): - return await workflow.execute_reserved( - harness.request, reservation, - preparation_request=harness.preparation_request, - ) - - async def revoke(): - return await authorization_router.revoke_project_role_grant( - project_id=project_id, grant_id=UUID(str(grant_id)), - payload=ProjectRoleGrantRevokeBody(reason="Concurrent pre-submit grant revocation"), - idempotency_key=new_record_id(), resolved=resolved_manager, - prepared=prepared_revoke, session=revoke_session, - ) - - if ordering == "materialization_first": - art_task = asyncio.create_task(execute_art()) - await asyncio.wait_for(art_before_project.wait(), timeout=30) - await _assert_row_locked(harness.engine, "actor_profiles", harness.actor_id) - await _assert_row_locked(harness.engine, "actor_identity_links", harness.identity_link_id) - revoke_task = asyncio.create_task(revoke()) - await _wait_for_named_owner(harness, isolated_database_env, revoke_name, art_name) - assert not revoke_has_project.is_set() - release_art.set() - await asyncio.wait_for(revoke_has_project.wait(), timeout=30) - assert revoke_pid is not None - await wait_for_named_database_lock( - isolated_database_env, art_name, expected_blocker_pid=revoke_pid, - ) - else: - revoke_task = asyncio.create_task(revoke()) - await asyncio.wait_for(revoke_has_project.wait(), timeout=30) - await _assert_row_locked(harness.engine, "actor_profiles", harness.actor_id) - await _assert_row_locked(harness.engine, "actor_identity_links", harness.identity_link_id) - release_art.set() - art_task = asyncio.create_task(execute_art()) - assert revoke_pid is not None - await wait_for_named_database_lock( - isolated_database_env, art_name, expected_blocker_pid=revoke_pid, - ) - assert not art_before_project.is_set() - release_revoke.set() - art_outcome, revoke_outcome = await asyncio.wait_for( - asyncio.gather(art_task, revoke_task, return_exceptions=True), - timeout=30, - ) - # Materialization and evidence persistence reauthorize in separate - # transactions. Revocation after checking still prevents evidence. - assert isinstance(art_outcome, ArtifactAuthorityDeniedError), art_outcome - assert calls == ([1] if ordering == "materialization_first" else []) - assert not isinstance(revoke_outcome, BaseException), revoke_outcome - assert revoke_outcome.status == "revoked" - finally: - release_art.set() - release_revoke.set() - for pending in (art_task, revoke_task): - if pending is not None and not pending.done(): - pending.cancel() - await asyncio.gather( - *(task for task in (art_task, revoke_task) if task is not None), - return_exceptions=True, - ) - materializer.close() - prepared_revoke.close() - async with harness.factory() as session: - grant = await session.get(ProjectRoleGrant, grant_id) - assert grant is not None and grant.status == "revoked" and grant.version == 2 - assert await session.scalar(select(func.count()).select_from(PreSubmitEvidenceSet)) == 0 - finally: - release_art.set() - release_revoke.set() - if art_engine is not None: - await art_engine.dispose() - if revoke_engine is not None: - await revoke_engine.dispose() - await harness.close() - - -@pytest.mark.asyncio -async def test_work_context_task_lock_precedes_art_actor_lock( - tmp_path: Path, isolated_database_env: str, -) -> None: - """TASK T then AUTH C and ART T then C complete without a cycle.""" - harness = await _harness(tmp_path, isolated_database_env) - art_engine = None - art_task = task_command = None - release_task = asyncio.Event() - try: - await _seed_materializer(harness.factory) - art_name = f"pol07a_task_art_{new_record_id().hex[:12]}" - art_engine = create_async_engine( - isolated_database_env, - connect_args={"server_settings": {"application_name": art_name}}, - ) - art_factory = async_sessionmaker(art_engine, expire_on_commit=False) - task_locked = asyncio.Event() - async with harness.factory() as task_session, art_factory() as art_session: - calls: list[int] = [] - workflow, materializer = await _ready_workflow(harness, art_session, calls) - reservation = await _reserve_with_real_materializer( - workflow, harness.request, harness.preparation_request, - ) - context = HumanAuthorizationContext( - actor_profile_id=harness.actor_id, actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, - identity_link_id=harness.identity_link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=new_record_id(), correlation_id=new_record_id(), - ) - task_authority = PreparedTaskAuthorization(task_session, context) - real_prepare = task_authority.prepare - task_pid: int | None = None - - async def pause_before_auth(facts): - nonlocal task_pid - task_pid = int(await task_session.scalar(text("select pg_backend_pid()"))) - task_locked.set() - await release_task.wait() - return await real_prepare(facts) - - task_authority.prepare = pause_before_auth - commands = AuthorizedTaskCommands( - task_session, - authorization=task_authority, - audit=task_transition_audit(task_session), - actor_profile_id=harness.actor_id, - contexts=task_service(task_session, settings=get_settings()), - guide_documents=task_guide_documents_port(task_session, get_settings()), - compensation_terms=locked_compensation_terms_port(task_session), - ) - try: - task_command = asyncio.create_task(commands.contributor_work_context(harness.request.task_id)) - await asyncio.wait_for(task_locked.wait(), timeout=30) - assert task_pid is not None - art_task = asyncio.create_task(workflow.execute_reserved( - harness.request, reservation, - preparation_request=harness.preparation_request, - )) - await _wait_for_project_block( - harness.engine, application_name=art_name, blocker_pid=task_pid, - ) - release_task.set() - task_result, art_result = await asyncio.wait_for( - asyncio.gather(task_command, art_task, return_exceptions=True), - timeout=30, - ) - assert not isinstance(task_result, BaseException), task_result - assert task_result.task.status == "in_progress" - assert task_result.lifecycle.assigned_to_current_actor is True - assert not isinstance(art_result, BaseException), art_result - assert art_result.pass_capability is not None - assert calls == [1] - finally: - release_task.set() - await asyncio.gather( - *(task for task in (art_task, task_command) if task is not None), - return_exceptions=True, - ) - materializer.close() - async with harness.factory() as session: - attempts = (await session.scalars(select(PreSubmitExecutionAttempt))).all() - assert len(attempts) == 1 and attempts[0].status == "completed" - assert await session.scalar(select(func.count()).select_from(PreSubmitEvidenceSet)) == 1 - finally: - release_task.set() - if art_engine is not None: - await art_engine.dispose() - await harness.close() diff --git a/backend/tests/test_pre_submit_role_issue_lock_order.py b/backend/tests/test_pre_submit_role_issue_lock_order.py deleted file mode 100644 index d6db93590..000000000 --- a/backend/tests/test_pre_submit_role_issue_lock_order.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Real PostgreSQL proof of role issuance versus pre-submit execution lock order.""" - -from __future__ import annotations - -import asyncio -from dataclasses import replace -from datetime import UTC, datetime -from pathlib import Path -from uuid import UUID -from app.core.identifiers import new_record_id - -import pytest -from sqlalchemy import func, select -from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from app.modules.actors.models import ActorIdentityLink, ActorProfile -from app.modules.actors.service import ResolvedActor -from app.modules.artifacts.authorization import PreparedPreSubmitMaterializationAuthorization -from app.modules.artifacts.models import PreSubmitEvidenceSet, PreSubmitExecutionAttempt -from app.core.api_controls import StructuredHTTPException -from app.modules.authorization import router as authorization_router -from app.modules.authorization.kernel import AuthorizationService -from app.modules.authorization.models import ProjectRoleGrant -from app.modules.authorization.prepared import PreparedAuthorizationService -from app.modules.authorization.project_role_schemas import ProjectRoleGrantIssueBody -from app.modules.authorization.repository import AdminAuthorizationRepository -from app.modules.authorization.runtime import ( - ActorKind, ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, -) -from app.modules.authorization.schemas import AdminRole, ProjectRole -from project_create_fixtures import grant_fixture_admin_role -from tests.authorization.test_pre_submit_attempt_authority import _seed_materializer -from tests.test_pre_submit_attempt_authority_integration import _reserve_with_real_materializer -from tests.test_pre_submit_attempt_lock_order import ( - _PauseAfterProjectLock, _wait_for_project_block, -) -from tests.test_pre_submit_attempt_recovery import _harness - - -async def _seed_manager(harness) -> tuple[UUID, UUID]: - actor_id, link_id = new_record_id(), new_record_id() - project_id = harness.request.effective_plan.lineage.project_id - async with harness.factory() as session: - session.add_all([ - ActorProfile( - id=str(actor_id), actor_kind="human", status="active", - provisioning_method="automatic_first_access", created_by="test", - ), - ActorIdentityLink( - id=str(link_id), actor_profile_id=str(actor_id), issuer="flow-test", - subject=f"role-issue-{actor_id}", subject_kind="human", - status="active", linked_by="test", last_verified_at=datetime.now(UTC), - ), - ]) - await session.flush() - await grant_fixture_admin_role( - session, actor_id, role=AdminRole.PROJECT_MANAGER.value, - scope="project", project_id=project_id, - ) - await session.commit() - return actor_id, link_id - - -def _issue_body(target_actor_id: UUID) -> ProjectRoleGrantIssueBody: - return ProjectRoleGrantIssueBody.model_validate({ - "target_actor_profile_id": target_actor_id, - "role": "reviewer", - "reason": "Concurrent project-role issue lock-order proof", - "qualification": { - "skills_snapshot": { - "availability": "available", "reference_ids": ["skill:reviewer"], - "unavailable_reason": None, - }, - "reputation_snapshot": { - "availability": "unavailable", "reference_ids": [], - "unavailable_reason": "no_record", - }, - "prior_project_work_refs": [], "external_expertise_refs": [], - }, - }) - - -async def _issue_runtime(session, actor_id: UUID, link_id: UUID): - context = HumanAuthorizationContext( - actor_profile_id=actor_id, actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, identity_link_id=link_id, - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=new_record_id(), correlation_id=new_record_id(), - ) - repository = AdminAuthorizationRepository(session) - authority = AuthorizationService(session, context, admin_repository=repository) - prepared = PreparedAuthorizationService(session, context, authority, repository) - profile = await session.get(ActorProfile, str(actor_id)) - link = await session.get(ActorIdentityLink, str(link_id)) - assert profile is not None and link is not None - await session.commit() - return prepared, ResolvedActor(profile, link) - - -async def _issue_role(session, project_id, body, issue_key, resolved, prepared): - try: - return await authorization_router.issue_project_role_grant( - project_id=project_id, payload=body, idempotency_key=issue_key, - resolved=resolved, prepared=prepared, session=session, - ) - except BaseException: - await session.rollback() - raise - - -async def _assert_persisted_outcomes(harness, project_id, target_id, target_kind, issue_result): - async with harness.factory() as session: - assert await session.scalar(select(func.count()).select_from(PreSubmitEvidenceSet)) == 1 - attempts = (await session.scalars(select(PreSubmitExecutionAttempt))).all() - assert len(attempts) == 1 and attempts[0].status == "completed" - grants = (await session.scalars(select(ProjectRoleGrant).where( - ProjectRoleGrant.project_id == str(project_id), - ProjectRoleGrant.actor_profile_id == str(target_id), - ProjectRoleGrant.role == "reviewer", ProjectRoleGrant.status == "active", - ))).all() - if target_kind == "contributor": - assert len(grants) == 1 and grants[0].id == issue_result.id - else: - assert grants == [] - - -def _sqlstate_cause(error: BaseException) -> str | None: - cause: BaseException | None = error - while cause is not None: - original = getattr(cause, "orig", None) - sqlstate = getattr(original, "sqlstate", None) - if sqlstate is not None: - return sqlstate - cause = cause.__cause__ - return None - - -@pytest.mark.parametrize("target_kind", ["contributor", "materializer_service"]) -@pytest.mark.asyncio -async def test_role_issue_and_pre_submit_execution_complete_without_project_contributor_deadlock( - tmp_path: Path, isolated_database_env: str, monkeypatch, target_kind: str, -) -> None: - import tests.test_default_pre_submit_execution as default_tests - - original_limits = default_tests._limits - monkeypatch.setattr(default_tests, "_limits", lambda: replace( - original_limits(), total_deadline_seconds=90, reservation_ttl_seconds=120, - aggregate_reserved_bytes=4 * default_tests.HARD_MAXIMUM_ARTIFACT_BYTES, - )) - harness = await _harness(tmp_path, isolated_database_env) - issue_engine = None - execution_task = issue_task = None - resume_project = asyncio.Event() - try: - manager_id, manager_link_id = await _seed_manager(harness) - materializer_link_id = await _seed_materializer(harness.factory) - target_id = harness.actor_id - if target_kind == "materializer_service": - async with harness.factory() as session: - target_id = UUID(await session.scalar(select( - ActorIdentityLink.actor_profile_id, - ).where(ActorIdentityLink.id == materializer_link_id))) - application_name = f"pol07a_role_issue_{new_record_id().hex[:12]}" - issue_engine = create_async_engine( - isolated_database_env, - connect_args={"server_settings": {"application_name": application_name}}, - ) - issue_factory = async_sessionmaker(issue_engine, expire_on_commit=False) - project_id = harness.request.effective_plan.lineage.project_id - async with harness.factory() as execution_session, issue_factory() as issue_session: - contributor_authority = harness.contributor_authority(execution_session) - await contributor_authority.preflight(request=harness.preparation_request) - async with execution_session.begin(): - await contributor_authority.revalidate( - request=harness.preparation_request, project_id=project_id, - ) - materializer = PreparedPreSubmitMaterializationAuthorization( - execution_session, - request_id=harness.preparation_request.request_id, - correlation_id=harness.preparation_request.correlation_id, - ) - calls: list[int] = [] - workflow = harness.workflow( - execution_session, calls, preparation_authorization=contributor_authority, - ) - workflow._materialization._authorization = materializer - prepared_issue, resolved_manager = await _issue_runtime( - issue_session, manager_id, manager_link_id, - ) - project_locked = asyncio.Event() - try: - reservation = await _reserve_with_real_materializer( - workflow, harness.request, harness.preparation_request, - ) - paused_project = _PauseAfterProjectLock( - workflow._project_contexts, execution_session, - project_locked, resume_project, - ) - workflow._project_contexts = paused_project - execution_task = asyncio.create_task(workflow.execute_reserved( - harness.request, reservation, - preparation_request=harness.preparation_request, - )) - await asyncio.wait_for(project_locked.wait(), timeout=30) - assert paused_project.backend_pid is not None - issue_key = new_record_id() - body = _issue_body(target_id) - issue_task = asyncio.create_task(_issue_role( - issue_session, project_id, body, issue_key, - resolved_manager, prepared_issue, - )) - await _wait_for_project_block( - harness.engine, application_name=application_name, - blocker_pid=paused_project.backend_pid, - ) - resume_project.set() - execution_result, issue_result = await asyncio.wait_for( - asyncio.gather(execution_task, issue_task, return_exceptions=True), - timeout=30, - ) - assert not isinstance(execution_result, BaseException), execution_result - assert execution_result.pass_capability is not None - if target_kind == "contributor": - assert not isinstance(issue_result, BaseException), ( - issue_result, _sqlstate_cause(issue_result) - ) - assert issue_result.status == "active" - assert issue_result.role is ProjectRole.REVIEWER - assert issue_result.actor_profile_id == harness.actor_id - replay = await authorization_router.issue_project_role_grant( - project_id=project_id, payload=body, idempotency_key=issue_key, - resolved=resolved_manager, prepared=prepared_issue, - session=issue_session, - ) - assert replay == issue_result - else: - assert isinstance(issue_result, StructuredHTTPException), issue_result - assert issue_result.status_code == 404, _sqlstate_cause(issue_result) - assert calls == [1] - finally: - resume_project.set() - await asyncio.gather( - *(task for task in (execution_task, issue_task) if task is not None), - return_exceptions=True, - ) - materializer.close() - prepared_issue.close() - await _assert_persisted_outcomes( - harness, project_id, target_id, target_kind, issue_result, - ) - finally: - resume_project.set() - await asyncio.gather( - *(task for task in (execution_task, issue_task) if task is not None), - return_exceptions=True, - ) - if issue_engine is not None: - await issue_engine.dispose() - await harness.close() - - -async def _assert_target_rows_locked(harness, target_id, link_id): - for model, row_id in ((ActorProfile, target_id), (ActorIdentityLink, link_id)): - async with harness.factory() as observer: - with pytest.raises(DBAPIError) as blocked: - await observer.scalar(select(model).where( - model.id == str(row_id), - ).with_for_update(nowait=True)) - assert blocked.value.orig.sqlstate == "55P03" - await observer.rollback() - - -@pytest.mark.asyncio -async def test_target_created_after_prepare_is_locked_before_project( - tmp_path: Path, isolated_database_env: str, monkeypatch, -) -> None: - harness = await _harness(tmp_path, isolated_database_env) - try: - manager_id, manager_link_id = await _seed_manager(harness) - project_id = harness.request.effective_plan.lineage.project_id - target_id, target_link_id = new_record_id(), new_record_id() - async with harness.factory() as session: - prepared, resolved = await _issue_runtime(session, manager_id, manager_link_id) - original_prepare = prepared.prepare - original_lock_project = AdminAuthorizationRepository.lock_project - project_probes = [] - - async def prepare_then_create(*args, **kwargs): - handle = await original_prepare(*args, **kwargs) - async with harness.factory.begin() as creator: - creator.add_all([ - ActorProfile( - id=str(target_id), actor_kind="human", status="active", - provisioning_method="automatic_first_access", created_by="test", - ), - ActorIdentityLink( - id=str(target_link_id), actor_profile_id=str(target_id), - issuer="flow-test", subject=f"late-target-{target_id}", - subject_kind="human", status="active", linked_by="test", - last_verified_at=datetime.now(UTC), - ), - ]) - return handle - - async def observe_project_lock(repository, requested_project_id): - await _assert_target_rows_locked(harness, target_id, target_link_id) - project_probes.append(requested_project_id) - return await original_lock_project(repository, requested_project_id) - - monkeypatch.setattr(prepared, "prepare", prepare_then_create) - monkeypatch.setattr( - AdminAuthorizationRepository, "lock_project", observe_project_lock, - ) - try: - issued = await _issue_role( - session, project_id, _issue_body(target_id), new_record_id(), - resolved, prepared, - ) - assert issued.status == "active" - assert issued.actor_profile_id == target_id - assert project_probes == [project_id] - finally: - prepared.close() - async with harness.factory() as session: - assert await session.scalar(select(func.count()).select_from( - ProjectRoleGrant, - ).where(ProjectRoleGrant.actor_profile_id == str(target_id))) == 1 - finally: - await harness.close() diff --git a/backend/tests/test_project_guide_compilation_contracts.py b/backend/tests/test_project_guide_compilation_contracts.py index 94b58fbcd..9822c6b69 100644 --- a/backend/tests/test_project_guide_compilation_contracts.py +++ b/backend/tests/test_project_guide_compilation_contracts.py @@ -1,17 +1,16 @@ - from __future__ import annotations from app.core.config import get_settings from app.adapters.tasks import task_service -from app.modules.checkers.catalogue import project_guide_pre_submission_capabilities - from tests.projects.guide_compilation.helpers import runtime_configuration +from tests.checkers.external.support import registry_spec +from datetime import UTC, datetime +import json from uuid import uuid4 -from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id import pytest @@ -32,31 +31,44 @@ validate_project_guide_compilation_result, ) from app.modules.projects.api.guide_documents import GuideDocumentManifest, GuideDocumentVersion -from app.modules.checkers.catalogue import ( - PreSubmissionCheckerClassification, - build_pre_submission_checker_catalogue, +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_CHECKER_ID, + WORKSTREAM_DEFAULT_CHECKER_VERSION, +) +from app.modules.checkers.api.external import ( + ExternalCheckerCapabilityDefinition, + ExternalCheckerCapabilityProjection, + ExternalCheckerRegistryEntry, + external_checker_json_hash, ) -from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue -from app.modules.projects.post_submit_policy import DEFAULT_DURABLE_CHECKERS SHA256 = "sha256:" + "a" * 64 -@pytest.mark.parametrize("project_limit, default_limit, expected", [ - (100, None, 100), (None, 50, 50), (100, 50, 50), (20, 50, 20), (None, None, None), -]) +@pytest.mark.parametrize( + "project_limit, default_limit, expected", + [ + (100, None, 100), + (None, 50, 50), + (100, 50, 50), + (20, 50, 20), + (None, None, None), + ], +) @pytest.mark.parametrize("field", ["maximum_archive_entries", "maximum_archive_size_bytes"]) -def test_archive_limit_projection_and_default_floor(monkeypatch, project_limit, default_limit, expected, field): +def test_archive_limit_projection_and_default_floor( + monkeypatch, project_limit, default_limit, expected, field +): from app.modules.projects import service from app.modules.projects.guide_compilation.projection_payloads import policy_body proposal = SubmissionArtifactPolicyProposal( - maximum_file_size_bytes=1000, maximum_package_size_bytes=2000, + maximum_file_size_bytes=1000, + maximum_package_size_bytes=2000, **{field: project_limit}, ) - monkeypatch.setitem(service.WORKSTREAM_DEFAULT_SUBMISSION_ARTIFACT_POLICY, - field, default_limit) + monkeypatch.setitem(service.WORKSTREAM_DEFAULT_SUBMISSION_ARTIFACT_POLICY, field, default_limit) body = policy_body(None, proposal) assert body[field] == project_limit effective = service.ProjectService(None)._merge_effective_submission_artifact_policy(body) @@ -66,11 +78,20 @@ def test_archive_limit_projection_and_default_floor(monkeypatch, project_limit, @pytest.mark.parametrize("field", ["required_evidence", "attestation_terms"]) -@pytest.mark.parametrize("value, valid", [ - ("results", True), ("a" + "b" * 99, True), ("results.v0_1-ok", True), - ("", False), ("a" + "b" * 100, False), ("Provide results", False), - ("evidence/results", False), ("9results", False), ("Results", False), -]) +@pytest.mark.parametrize( + "value, valid", + [ + ("results", True), + ("a" + "b" * 99, True), + ("results.v0_1-ok", True), + ("", False), + ("a" + "b" * 100, False), + ("Provide results", False), + ("evidence/results", False), + ("9results", False), + ("Results", False), + ], +) def test_policy_identifier_schema_matches_existing_validator(field, value, valid): import re from app.interfaces.project_agents import _SAFE_IDENTIFIER @@ -93,7 +114,9 @@ def test_model_schema_describes_existing_expanded_byte_limit(): properties = SubmissionArtifactPolicyProposal.model_json_schema()["properties"] assert "expanded bytes" in properties["maximum_package_size_bytes"]["description"] assert "not compressed upload bytes" in properties["maximum_package_size_bytes"]["description"] - assert "entire submitted compressed ZIP" in properties["maximum_archive_size_bytes"]["description"] + assert ( + "entire submitted compressed ZIP" in properties["maximum_archive_size_bytes"]["description"] + ) assert "directory entries" in properties["maximum_archive_entries"]["description"] assert "ZIP root" in properties["required_artifacts"]["description"] @@ -107,8 +130,7 @@ def test_omitted_optional_archive_limit_preserves_default_floor(monkeypatch, def owner = service.ProjectService(None) policy = owner._canonical_policy_body(SubmissionArtifactPolicyInput().model_dump()) del policy[field] - monkeypatch.setitem(service.WORKSTREAM_DEFAULT_SUBMISSION_ARTIFACT_POLICY, - field, default_limit) + monkeypatch.setitem(service.WORKSTREAM_DEFAULT_SUBMISSION_ARTIFACT_POLICY, field, default_limit) effective = owner._merge_effective_submission_artifact_policy(policy) assert effective[field] == default_limit assert field not in policy @@ -128,31 +150,104 @@ def test_task_requirements_response_carries_valid_locked_archive_limit(limit, fi ) policy[field] = limit from app.modules.projects.api.locked_policy import CanonicalJsonObject - context = SimpleNamespace(facts=SimpleNamespace(effective_policy=CanonicalJsonObject.from_mapping(policy))) + + context = SimpleNamespace( + facts=SimpleNamespace(effective_policy=CanonicalJsonObject.from_mapping(policy)) + ) task = SimpleNamespace(id=str(uuid4()), project_id=str(uuid4()), locked_guide_version="v0.1") if limit is not None and (type(limit) is not int or limit <= 0): with pytest.raises(TaskLockedContextInvalid): - task_service(None, settings=get_settings())._contributor_submission_requirements_response(task, context) + task_service( + None, settings=get_settings() + )._contributor_submission_requirements_response(task, context) else: - response = task_service(None, settings=get_settings())._contributor_submission_requirements_response(task, context) + response = task_service( + None, settings=get_settings() + )._contributor_submission_requirements_response(task, context) assert response.model_dump(mode="json")[field] == limit + + SOURCE_ITEM_ID = new_record_id() DOCUMENT_VERSION_ID = new_record_id() +def _registered_capability(capability_id: str, phase: str, capability_version: str = "v1.2.3"): + spec = registry_spec(phase).model_copy( + update={ + "capability_id": capability_id, + "capability_version": capability_version, + } + ) + spec = type(spec).model_validate(spec.model_dump()) + entry = ExternalCheckerRegistryEntry( + **spec.model_dump(), + registry_entry_id=new_record_id(), + entry_digest=spec.spec_digest, + registration_operation_id=uuid4(), + registered_by_actor_profile_id=uuid4(), + authorization_decision_event_id=uuid4(), + created_at=datetime.now(UTC), + ) + return ExternalCheckerCapabilityDefinition( + capability_id=capability_id, + capability_version=spec.capability_version, + phase=phase, + selectable=True, + registry_entry=entry, + ) + + +def _capabilities() -> ExternalCheckerCapabilityProjection: + definitions = tuple( + sorted( + ( + ExternalCheckerCapabilityDefinition( + capability_id=WORKSTREAM_DEFAULT_CHECKER_ID, + capability_version=WORKSTREAM_DEFAULT_CHECKER_VERSION, + phase="pre_submit", + selectable=False, + ), + _registered_capability("acme.pre", "pre_submit"), + _registered_capability("acme.post", "post_submit"), + ), + key=lambda item: (item.phase, item.capability_id, item.capability_version), + ) + ) + body = { + "schema_version": "external_checker_capabilities.v1", + "definitions": [item.model_dump(mode="json") for item in definitions], + } + return ExternalCheckerCapabilityProjection( + definitions=definitions, + manifest_sha256=external_checker_json_hash(body), + ) + + def _context() -> ProjectGuideCompilationContext: setup_id = new_record_id() material = GuideDocumentManifest( - project_id=new_record_id(), guide_id=new_record_id(), guide_version="v1", - source_snapshot_id=new_record_id(), source_snapshot_hash=SHA256, - setup_run_id=setup_id, setup_generation=1, - documents=(GuideDocumentVersion( - source_item_id=SOURCE_ITEM_ID, ingest_id=DOCUMENT_VERSION_ID, item_order=0, - put_attempt_id=new_record_id(), content_id=new_record_id(), - replica_id=new_record_id(), - storage_namespace_id="primary", namespace_fingerprint=SHA256, - sha256=SHA256, byte_count=100, media_type="application/pdf", - ),), + project_id=new_record_id(), + guide_id=new_record_id(), + guide_version="v1", + source_snapshot_id=new_record_id(), + source_snapshot_hash=SHA256, + setup_run_id=setup_id, + setup_generation=1, + documents=( + GuideDocumentVersion( + source_item_id=SOURCE_ITEM_ID, + ingest_id=DOCUMENT_VERSION_ID, + item_order=0, + put_attempt_id=new_record_id(), + content_id=new_record_id(), + replica_id=new_record_id(), + storage_namespace_id="primary", + namespace_fingerprint=SHA256, + sha256=SHA256, + byte_count=100, + media_type="application/pdf", + ), + ), ) return ProjectGuideCompilationContext( task_examples=({"content": "Review a claim using the project guide."},), @@ -162,10 +257,7 @@ def _context() -> ProjectGuideCompilationContext: instruction_version="v1", agent_identity="project-guide-compilation-agent-v1", agent_version="v1", - pre_submission_capabilities=project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue() - ), - post_submission_capabilities=current_post_submit_catalogue(), + checker_capabilities=_capabilities(), runtime_configuration=runtime_configuration(), ) @@ -188,125 +280,47 @@ def _artifact_policy() -> SubmissionArtifactPolicyProposal: ) -def test_pre_submission_projection_preserves_exact_manifest_and_selectability() -> None: - catalogue = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) - ) - projection = project_guide_pre_submission_capabilities(catalogue) +def test_checker_projection_has_one_default_and_exact_registry_entries() -> None: + projection = _capabilities() + defaults = [item for item in projection.definitions if not item.selectable] + registered = [item for item in projection.definitions if item.selectable] - assert projection.manifest_sha256 == catalogue.manifest_sha256 - assert len(projection.definitions) == len(catalogue.entries) - assert [item.stable_id for item in projection.definitions] == [ - item.stable_id for item in catalogue.entries + assert [(item.capability_id, item.phase) for item in defaults] == [ + ("workstream.default", "pre_submit") ] - for definition, source in zip(projection.definitions, catalogue.entries, strict=True): - projected = definition.model_dump(mode="json") - selectable = projected.pop("selectable") - assert projected == source.manifest_entry() - assert selectable is ( - source.state.value == "enabled" - and source.dispatch_kind.value == "policy_primitive" - and source.phase.value == "project_policy" - ) - disabled = next( - item - for item in projection.definitions - if item.stable_id == "artifact.quality.placeholder_signal" - ) - assert disabled.state == "disabled" - assert disabled.selectable is False - assert projection.available is True - - -def test_pre_submission_projection_reports_disabled_mandatory_unavailable() -> None: - catalogue = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) - ) - projection = project_guide_pre_submission_capabilities(catalogue) - assert projection.available is False - assert projection.definitions[0].classification == ( - PreSubmissionCheckerClassification.MANDATORY_SECURITY.value - ) - assert projection.definitions[0].selectable is False - - -@pytest.mark.parametrize("disabled", [False, True]) -def test_archive_encryption_guard_is_described_as_mandatory_platform_coverage(disabled): - from app.interfaces.project_agents import project_guide_compilation_prompt_bytes - - capability_id = "artifact.archive.entries_safe" - catalogue = build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({capability_id}) if disabled else frozenset() - ) - projection = project_guide_pre_submission_capabilities(catalogue) - definition = next(item for item in projection.definitions if item.stable_id == capability_id) - expected = "Reject encrypted ZIP entries, symbolic links and special files" - assert definition.public_name == expected - manifest_entry = next(item for item in catalogue.manifest["entries"] - if item["stable_id"] == capability_id) - assert manifest_entry["public_name"] == expected - assert definition.classification == "mandatory_security" - assert definition.dispatch_kind == "platform_capability" - assert definition.dispatch_capability == "submission_archive.entries_safe" - assert definition.selectable is False - assert definition.state == ("disabled" if disabled else "enabled") - assert projection.available is (not disabled) - context = _context().model_copy(update={"pre_submission_capabilities": projection}) - assert expected.encode() in project_guide_compilation_prompt_bytes(context) - - -def test_compilation_rejects_unavailable_mandatory_pre_submission_projection() -> None: - context = _context().model_copy( - update={ - "pre_submission_capabilities": project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) - ) - ) - } - ) - result = ProjectGuideCompilationResult( - status="draft_ready", - findings=( - CompilationFinding(severity="info", code="guide.ready", message="Guide is complete."), - ), - submission_artifact_policy=_artifact_policy(), - agent_version="v1", - ) - with pytest.raises(ValueError, match="projection is unavailable"): - validate_project_guide_compilation_result(context, result) - - -def test_post_submission_projection_uses_registry_and_frozen_default_truth() -> None: - projection = current_post_submit_catalogue() - by_name = {item.capability_id: item for item in projection.definitions} - - assert projection.source_version == "v0.1" - assert set(DEFAULT_DURABLE_CHECKERS).issubset(by_name) - assert {name for name, item in by_name.items() if item.platform_default} == set( - DEFAULT_DURABLE_CHECKERS - ) - assert [item.capability_id for item in projection.definitions if item.selectable] == [ - "check_acceptance_criteria_present" + assert [(item.capability_id, item.phase) for item in registered] == [ + ("acme.post", "post_submit"), + ("acme.pre", "pre_submit"), ] - assert all(item.stage == "post_submit" for item in projection.definitions) + assert all(item.registry_entry is not None for item in registered) -def test_post_submission_projection_rejects_sparse_definitions() -> None: - from app.modules.checkers.api.post_submit_catalogue import PostSubmitCatalogue - - projection = current_post_submit_catalogue() +def test_checker_projection_rejects_a_second_code_owned_default() -> None: + projection = _capabilities() body = projection.model_dump(mode="json") - sparse_keys = {"capability_id", "capability_version", "stage", "platform_default", "selectable"} - body["definitions"] = [ - {key: value for key, value in item.items() if key in sparse_keys} - for item in body["definitions"] - ] - body["manifest_sha256"] = canonical_json_hash( - {key: value for key, value in body.items() if key != "manifest_sha256"} + body["definitions"] = ( + *body["definitions"], + { + "capability_id": "workstream.other", + "capability_version": "v1", + "phase": "post_submit", + "selectable": False, + "registry_entry": None, + }, + ) + body["definitions"] = tuple( + sorted( + body["definitions"], + key=lambda item: (item["phase"], item["capability_id"], item["capability_version"]), + ) ) - with pytest.raises(ValidationError, match="Field required"): - PostSubmitCatalogue.model_validate(body) + manifest = { + "schema_version": body["schema_version"], + "definitions": list(body["definitions"]), + } + body["manifest_sha256"] = external_checker_json_hash(manifest) + with pytest.raises(ValidationError, match="default capability"): + ExternalCheckerCapabilityProjection.model_validate_json(json.dumps(body)) @pytest.mark.parametrize( @@ -357,8 +371,13 @@ def test_guide_evidence_ref_rejects_non_lineage_fields(payload: dict[str, str], ) def test_model_produced_text_rejects_unsafe_shapes(unsafe: str) -> None: with pytest.raises(ValidationError): - CapabilitySuggestion(requirement_id="r001", stage="pre_submit", - title="new checker", rationale=unsafe, evidence_refs=(_evidence(),)) + CapabilitySuggestion( + requirement_id="r001", + stage="pre_submit", + title="new checker", + rationale=unsafe, + evidence_refs=(_evidence(),), + ) @pytest.mark.parametrize( @@ -370,8 +389,13 @@ def test_model_produced_text_rejects_unsafe_shapes(unsafe: str) -> None: ], ) def test_model_produced_text_allows_safe_security_policy_language(safe: str) -> None: - suggestion = CapabilitySuggestion(requirement_id="r001", stage="pre_submit", - title="security guidance", rationale=safe, evidence_refs=(_evidence(),)) + suggestion = CapabilitySuggestion( + requirement_id="r001", + stage="pre_submit", + title="security guidance", + rationale=safe, + evidence_refs=(_evidence(),), + ) assert suggestion.rationale == safe @@ -380,8 +404,13 @@ def test_compilation_context_requires_task_examples_and_preserves_all_input() -> import json payload = _context().model_dump(mode="json") - examples = [{"content": "A starting idea."}, {"content": " 修復 worker\n", "title": "Second", "labels": ["research", "repair"]}] - context = ProjectGuideCompilationContext.model_validate(payload | {"task_examples": examples}) + examples = [ + {"content": "A starting idea."}, + {"content": " 修復 worker\n", "title": "Second", "labels": ["research", "repair"]}, + ] + context = ProjectGuideCompilationContext.model_validate_json( + json.dumps(payload | {"task_examples": examples}) + ) sent = json.loads(project_guide_compilation_prompt_bytes(context)) assert sent["task_examples"] == [item.model_dump(mode="json") for item in context.task_examples] del payload["task_examples"] @@ -402,24 +431,46 @@ def test_maximum_examples_and_documents_fit_default_runtime_input_budgets() -> N from app.modules.projects.api.task_examples import MAXIMUM_TASK_EXAMPLE_BYTES context = _context() - examples = [{"content": "x" * 65_536, "title": None, "labels": []}, - {"content": "", "title": None, "labels": []}] + examples = [ + {"content": "x" * 65_536, "title": None, "labels": []}, + {"content": "", "title": None, "labels": []}, + ] overhead = len(json.dumps(examples, sort_keys=True, separators=(",", ":")).encode()) examples[1]["content"] = "y" * (MAXIMUM_TASK_EXAMPLE_BYTES - overhead) - assert len(json.dumps(examples, sort_keys=True, separators=(",", ":")).encode()) == MAXIMUM_TASK_EXAMPLE_BYTES + assert ( + len(json.dumps(examples, sort_keys=True, separators=(",", ":")).encode()) + == MAXIMUM_TASK_EXAMPLE_BYTES + ) prototype = context.material.documents[0].model_dump(mode="json") - documents = [prototype | { - "source_item_id": str(uuid4()), "ingest_id": str(uuid4()), - "put_attempt_id": str(uuid4()), "item_order": index, - } for index in range(100)] - material = context.material.model_dump(mode="json") | {"documents": documents, "guide_version": "g" * 50} - largest = ProjectGuideCompilationContext.model_validate( - context.model_dump(mode="json") | {"task_examples": examples, "material": material}, + documents = [ + prototype + | { + "source_item_id": str(uuid4()), + "ingest_id": str(uuid4()), + "put_attempt_id": str(uuid4()), + "item_order": index, + } + for index in range(100) + ] + material = context.material.model_dump(mode="json") | { + "documents": documents, + "guide_version": "g" * 50, + } + largest = ProjectGuideCompilationContext.model_validate_json( + json.dumps( + context.model_dump(mode="json") | {"task_examples": examples, "material": material} + ), ) assert len(largest.task_examples) == 2 assert len(largest.material.documents) == 100 - assert len(project_guide_compilation_prompt_bytes(largest)) <= largest.runtime_configuration.maximum_manifest_bytes - assert len(canonical_project_guide_compilation_context_bytes(largest)) <= MAXIMUM_PROJECT_GUIDE_COMPILATION_PROMPT_BYTES + assert ( + len(project_guide_compilation_prompt_bytes(largest)) + <= largest.runtime_configuration.maximum_manifest_bytes + ) + assert ( + len(canonical_project_guide_compilation_context_bytes(largest)) + <= MAXIMUM_PROJECT_GUIDE_COMPILATION_PROMPT_BYTES + ) def test_unified_result_accepts_exact_stage_capability_and_closed_parameters() -> None: @@ -446,16 +497,16 @@ def test_unified_result_accepts_exact_stage_capability_and_closed_parameters() - pre_submit_bindings=( PreSubmissionBindingProposal( requirement_id="requirement.packet", - capability_id="policy.file_size.limit", - capability_version="v1", + capability_id="acme.pre", + capability_version="v1.2.3", stage="pre_submit", ), ), post_submit_bindings=( PostSubmissionBindingProposal( requirement_id="requirement.acceptance", - capability_id="check_acceptance_criteria_present", - capability_version="v0.1", + capability_id="acme.post", + capability_version="v1.2.3", stage="post_submit", ), ), @@ -464,21 +515,82 @@ def test_unified_result_accepts_exact_stage_capability_and_closed_parameters() - validate_project_guide_compilation_result(context, result) +def test_unified_result_selects_each_exact_registered_capability_version() -> None: + definitions = tuple( + sorted( + ( + ExternalCheckerCapabilityDefinition( + capability_id=WORKSTREAM_DEFAULT_CHECKER_ID, + capability_version=WORKSTREAM_DEFAULT_CHECKER_VERSION, + phase="pre_submit", + selectable=False, + ), + _registered_capability("acme.pre", "pre_submit", "v1.0.0"), + _registered_capability("acme.pre", "pre_submit", "v2.0.0"), + _registered_capability("acme.post", "post_submit"), + ), + key=lambda item: (item.phase, item.capability_id, item.capability_version), + ) + ) + body = { + "schema_version": "external_checker_capabilities.v1", + "definitions": [item.model_dump(mode="json") for item in definitions], + } + capabilities = ExternalCheckerCapabilityProjection( + definitions=definitions, + manifest_sha256=external_checker_json_hash(body), + ) + context = _context().model_copy(update={"checker_capabilities": capabilities}) + + for version in ("v1.0.0", "v2.0.0"): + result = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.packet", + statement="Validate the submission packet.", + disposition="supported_pre_submit", + ), + ), + pre_submit_bindings=( + PreSubmissionBindingProposal( + requirement_id="requirement.packet", + capability_id="acme.pre", + capability_version=version, + stage="pre_submit", + ), + ), + agent_version="v1", + ) + validate_project_guide_compilation_result(context, result) + + wrong_version = result.model_copy( + update={ + "pre_submit_bindings": ( + result.pre_submit_bindings[0].model_copy(update={"capability_version": "v1.5.0"}), + ) + } + ) + with pytest.raises(ValueError, match="version is stale"): + validate_project_guide_compilation_result(context, wrong_version) + + @pytest.mark.parametrize( ("capability_id", "version", "stage", "expected_error"), [ - ("submission.packet.required_fields", "v1", "pre_submit", "binding is invalid"), - ("policy.submission_packet.validate", "stale", "pre_submit", "version is stale"), + ("workstream.default", "v1", "pre_submit", "binding is invalid"), + ("acme.pre", "stale", "pre_submit", "version is stale"), ("unknown.capability", "v1", "pre_submit", "binding is invalid"), ( - "check_submission_packet", - "v0.1", + "acme.pre", + "v1.2.3", "post_submit", "binding is invalid", ), ( - "check_acceptance_criteria_present", - "v0.1", + "acme.post", + "v1.2.3", "pre_submit", "binding is invalid", ), @@ -489,7 +601,9 @@ def test_unified_result_rejects_default_unknown_stale_and_wrong_stage_bindings( ) -> None: context = _context() disposition = "supported_post_submit" if stage == "post_submit" else "supported_pre_submit" - binding_type = PreSubmissionBindingProposal if stage == "pre_submit" else PostSubmissionBindingProposal + binding_type = ( + PreSubmissionBindingProposal if stage == "pre_submit" else PostSubmissionBindingProposal + ) binding = binding_type( requirement_id="requirement.one", capability_id=capability_id, @@ -549,10 +663,11 @@ def test_unified_result_rejects_open_nested_or_executable_configuration() -> Non ) -def test_pre_submission_projection_resource_budget_is_deeply_frozen() -> None: - projection = project_guide_pre_submission_capabilities(build_pre_submission_checker_catalogue()) +def test_external_registry_resource_budget_is_deeply_frozen() -> None: + projection = _capabilities() + registered = next(item for item in projection.definitions if item.registry_entry) with pytest.raises(ValidationError): - projection.definitions[0].resource_budget.maximum_results = 99 + registered.registry_entry.resources.deadline_ms = 99 @pytest.mark.parametrize("field", ["canonical_content", "content_markdown", "provider_url"]) @@ -562,7 +677,9 @@ def test_manifest_rejects_document_bodies_and_untrusted_storage_coordinates(fiel GuideDocumentManifest.model_validate(body | {field: "untrusted content"}) -@pytest.mark.parametrize("field", ["source_item_id", "ingest_id", "put_attempt_id", "replica_id", "sha256"]) +@pytest.mark.parametrize( + "field", ["source_item_id", "ingest_id", "put_attempt_id", "replica_id", "sha256"] +) def test_manifest_requires_exact_document_identity(field): body = _context().material.model_dump(mode="python") document = dict(body["documents"][0]) @@ -636,8 +753,8 @@ def test_post_submission_binding_rejects_unowned_parameters() -> None: context = _context() binding = PostSubmissionBindingProposal( requirement_id="requirement.one", - capability_id="check_acceptance_criteria_present", - capability_version="v0.1", + capability_id="acme.post", + capability_version="v1.2.3", stage="post_submit", parameters=(CapabilityParameter(name="unowned_parameter", value=True),), ) @@ -654,7 +771,7 @@ def test_post_submission_binding_rejects_unowned_parameters() -> None: post_submit_bindings=(binding,), agent_version="v1", ) - with pytest.raises(ValueError, match="Extra inputs"): + with pytest.raises(ValueError, match="value is invalid"): validate_project_guide_compilation_result(context, result) @@ -701,7 +818,7 @@ def test_result_status_must_match_findings_and_blocking_dispositions( @pytest.mark.parametrize( "invalid_capability_id", - ["policy.submission_packet.validate", "artifact.quality.placeholder_signal"], + ["acme.pre", "unknown.capability"], ) def test_platform_coverage_requires_exact_mandatory_platform_capability( invalid_capability_id: str, @@ -716,7 +833,7 @@ def test_platform_coverage_requires_exact_mandatory_platform_capability( statement="Validate the outer ZIP.", disposition="platform_covered", platform_coverage=PlatformCoverageRef( - capability_id="artifact.outer_zip.valid", + capability_id="workstream.default", capability_version="v1", stage="pre_submit", ), @@ -745,16 +862,8 @@ def test_platform_coverage_requires_exact_mandatory_platform_capability( validate_project_guide_compilation_result(context, invalid) -def test_platform_coverage_rejects_disabled_platform_capability() -> None: - context = _context().model_copy( - update={ - "pre_submission_capabilities": project_guide_pre_submission_capabilities( - build_pre_submission_checker_catalogue( - disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) - ) - ) - } - ) +def test_platform_coverage_rejects_registered_project_image() -> None: + context = _context() result = ProjectGuideCompilationResult( status="draft_ready", submission_artifact_policy=_artifact_policy(), @@ -764,19 +873,19 @@ def test_platform_coverage_rejects_disabled_platform_capability() -> None: statement="Validate the outer ZIP.", disposition="platform_covered", platform_coverage=PlatformCoverageRef( - capability_id="artifact.outer_zip.valid", - capability_version="v1", + capability_id="acme.pre", + capability_version="v1.2.3", stage="pre_submit", ), ), ), agent_version="v1", ) - with pytest.raises(ValueError, match="projection is unavailable"): + with pytest.raises(ValueError, match="canonical truth"): validate_project_guide_compilation_result(context, result) -def test_platform_coverage_accepts_exact_post_submit_default() -> None: +def test_platform_coverage_rejects_post_submit_project_image() -> None: result = ProjectGuideCompilationResult( status="draft_ready", submission_artifact_policy=_artifact_policy(), @@ -786,15 +895,16 @@ def test_platform_coverage_accepts_exact_post_submit_default() -> None: statement="Run the platform submission-packet check.", disposition="platform_covered", platform_coverage=PlatformCoverageRef( - capability_id="check_submission_packet", - capability_version="v0.1", + capability_id="acme.post", + capability_version="v1.2.3", stage="post_submit", ), ), ), agent_version="v1", ) - validate_project_guide_compilation_result(_context(), result) + with pytest.raises(ValueError, match="canonical truth"): + validate_project_guide_compilation_result(_context(), result) def test_platform_coverage_ref_is_required_only_for_platform_disposition() -> None: @@ -853,17 +963,36 @@ def test_blocked_result_cannot_publish_policy_or_bindings() -> None: @pytest.mark.parametrize("parameters", [[], [{"name": "maximum_file_size_bytes", "value": 1000}]]) -def test_pre_submission_binding_rejects_duplicate_policy_configuration(parameters): - """The sole intake configuration is the artifact policy, never a second copy.""" - with pytest.raises(ValidationError, match="Extra inputs"): - PreSubmissionBindingProposal( - requirement_id="requirement.size", capability_id="policy.file_size.limit", - capability_version="v1", stage="pre_submit", parameters=parameters, - ) +def test_pre_submission_binding_accepts_explicit_checker_configuration(parameters): + """Approved PRE image configuration is distinct from platform intake limits.""" + binding = PreSubmissionBindingProposal( + requirement_id="requirement.size", + capability_id="policy.file_size.limit", + capability_version="v1", + stage="pre_submit", + parameters=parameters, + ) + assert [item.model_dump(mode="json") for item in binding.parameters] == parameters def test_context_rejects_instruction_version_separate_from_snapshot() -> None: payload = _context().model_dump(mode="json") payload["instruction_version"] = "different" with pytest.raises(ValidationError, match="instruction configuration mismatch"): - ProjectGuideCompilationContext.model_validate(payload) + ProjectGuideCompilationContext.model_validate_json(json.dumps(payload)) + + +@pytest.mark.parametrize("value", ["outputs/answer.md", "src/main.py", "tests/**/*.py"]) +def test_checker_configuration_accepts_canonical_relative_paths(value: str) -> None: + parameter = CapabilityParameter(name="required_path", value=value) + assert parameter.value == value + + +@pytest.mark.parametrize("value", [ + "/etc/passwd", "../answer.md", "src/../answer.md", "https://example.invalid/x", + "file:///tmp/x", "src/answer.md\x00", "src/$(command)", "bash/run", + "token=secret", "Bearer opaque-token", "import subprocess", "src\\main.py", +]) +def test_checker_configuration_rejects_unsafe_machine_values(value: str) -> None: + with pytest.raises(ValidationError): + CapabilityParameter(name="required_path", value=value) diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index e35151ba5..9f03053b8 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -33,6 +33,7 @@ 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.modules.checkers.api.external_policy import validate_stored_external_pre_policy from app.db import session as db_session from app.db.base import Base from app.main import create_app @@ -4001,16 +4002,14 @@ async def test_submission_artifact_policy_approval_persists_effective_policy_has ref.startswith("guide-document:") and "#sha256:" in ref for ref in persisted_policy.source_material_refs ) - assert pre_submit_checker_policy is not None assert pre_submit_checker_policy.lifecycle_status == "compiled" assert pre_submit_checker_policy.effective_policy_hash == effective["effective_policy_hash"] - assert pre_submit_checker_policy.compiler_version == "workstream-pre-submit-compiler-v0.1" - assert pre_submit_checker_policy.compiled_bundle_hash is not None - assert ( - pre_submit_checker_policy.compiled_bundle["effective_policy_hash"] - == (effective["effective_policy_hash"]) + assert pre_submit_checker_policy.compiler_version == "external_checker_policy.v1" + locked_pre_policy = validate_stored_external_pre_policy( + pre_submit_checker_policy.compiled_bundle ) - assert "require_file" in pre_submit_checker_policy.checker_configs + assert locked_pre_policy.policy_sha256 == pre_submit_checker_policy.compiled_bundle_hash + assert locked_pre_policy.entries == () async def test_approved_submission_artifact_policy_cannot_be_updated( diff --git a/backend/tests/test_review_queue_persistence.py b/backend/tests/test_review_queue_persistence.py index f28426ad3..36be75e60 100644 --- a/backend/tests/test_review_queue_persistence.py +++ b/backend/tests/test_review_queue_persistence.py @@ -32,6 +32,7 @@ ) from app.modules.tasks.models import Submission from project_create_fixtures import grant_system_project_manager, insert_historical_project +from tests.checkers.external.support import post_registry_spec from tests.submission_fixtures import seed_retained_submission, seed_retained_checker_run from tests.test_tasks import ( auth_headers, @@ -87,7 +88,7 @@ async def _reviewable_lineage( monkeypatch: pytest.MonkeyPatch, *, checker_status: str = "completed", ) -> tuple[dict, dict, dict]: - project = await create_active_project(client) + project = await create_active_project(client, post_submit_registry_spec=post_registry_spec()) task = await create_started_task( client, project["id"], @@ -370,11 +371,11 @@ async def test_database_rejects_non_admissible_checker_and_project_mismatch( original_run = submission["checker_run_id"] # Each state rejection uses the current generation. Supersession separately # tests an otherwise admissible completed/allow_review predecessor. - for generation, state, failures in ( - (2, "running", ()), (3, "completed", ("check_submission_packet",)), + for generation, state, verdict in ( + (2, "running", "passed"), (3, "completed", "failed"), ): current = await seed_retained_checker_run(submission["id"], state=state, - failures=failures, generation=generation) + verdict=verdict, generation=generation) invalid = submission | {"checker_run_id": current} async with db_session.get_session_factory()() as session: session.add(ReviewQueueEntry(**_queue_input(project, task, invalid).model_dump())) diff --git a/backend/tests/test_submission_bundle_admission.py b/backend/tests/test_submission_bundle_admission.py index 1ef664709..f80f971ef 100644 --- a/backend/tests/test_submission_bundle_admission.py +++ b/backend/tests/test_submission_bundle_admission.py @@ -2,9 +2,7 @@ from __future__ import annotations -import asyncio from contextlib import asynccontextmanager -from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import AsyncMock, Mock from app.core.identifiers import new_record_id @@ -14,22 +12,14 @@ import app.adapters.artifacts as artifact_adapters from app.core.config import Settings -from app.modules.artifacts.pre_submit_evidence import ( - PreSubmitEvidenceConflict, - PreSubmitEvidenceService, - PreSubmitPassCapability, -) from app.modules.artifacts.api import ( SubmissionBundlePreparationRejected, SubmissionBundlePreparationRequest, SubmissionBundlePreparationResult, SubmissionBundlePreparationUnavailable, ) -from app.modules.artifacts.preparation import ArtifactPreparationService, ArtifactScratchManager from app.modules.artifacts.schemas import ( - ArtifactAdmissionResult, ArtifactAuthorityDeniedError, - SubmissionBundleArtifactAdmissionRequest, ) from app.interfaces.artifacts import ( ArtifactConfigurationError, @@ -40,25 +30,14 @@ DenySubmissionBundlePreparationAuthorization, ) from app.modules.artifacts.submission_admission import ( - SubmissionBundleDurablePutRequest, SubmissionBundleDurablePutResult, - SubmissionBundleDurablePutService, -) -from app.modules.artifacts.submission_materialization import ( - PreparedBundlePreSubmitEvidenceService, ) -from app.modules.artifacts.submission_admission import ( - SubmissionBundleAdmissionPublicationError, - SubmissionBundleAdmissionPublisher, -) -from app.modules.artifacts.service import ArtifactAdmissionService -from app.modules.artifacts.submission_custody import SubmissionBundlePreparedCustody from app.modules.artifacts.submission_admission import ( PreparedSubmissionBundlePreparationCommand, ) from app.modules.authorization.api import ActorIdentityFacts, ActorKind from app.modules.authorization.prepared import PreparedAuthorizationHandle -from tests.artifact_store_helpers import artifact_byte_stream, artifact_preparation_limits +from tests.artifact_store_helpers import artifact_byte_stream from tests.artifact_store_helpers import artifact_admission_limit_settings from app.main import create_app from app.api.routes.artifact_submissions import router as submission_router @@ -131,7 +110,8 @@ async def test_actor_prelock_requires_a_root_transaction(active, nested) -> None authority = object.__new__(PreparedSubmissionBundlePreparationAuthorization) authority._session = SimpleNamespace( - in_transaction=lambda: active, in_nested_transaction=lambda: nested, + in_transaction=lambda: active, + in_nested_transaction=lambda: nested, ) authority._repository = SimpleNamespace(lock_request_actor=AsyncMock()) with pytest.raises(ArtifactAuthorityDeniedError): @@ -239,7 +219,6 @@ def test_artifact_adapter_composes_submission_command_from_owner_ports( request.scope["app"] = SimpleNamespace( state=SimpleNamespace( settings=Settings(), - pre_submission_checker_catalogue=object(), ) ) session = object() @@ -292,11 +271,7 @@ def test_submission_packet_headers_reject_non_ascii() -> None: SubmissionBundlePreparationRejected, match="submission_bundle_packet_header_encoding_invalid", ): - validate_submission_packet_headers( - "caf\N{LATIN SMALL LETTER E WITH ACUTE}", "ok" - ) - - + validate_submission_packet_headers("caf\N{LATIN SMALL LETTER E WITH ACUTE}", "ok") @asynccontextmanager @@ -304,18 +279,6 @@ async def _transaction(): yield -async def _prepared(tmp_path): - manager = ArtifactScratchManager( - root=tmp_path / "scratch", - limits=artifact_preparation_limits(), - ) - prepared = await ArtifactPreparationService(manager).prepare( - artifact_byte_stream(b"PK\x03\x04checked submission"), - media_type="application/zip", - ) - return manager, prepared - - @pytest.mark.asyncio async def test_hidden_preparation_denies_before_reading_uploaded_bytes() -> None: reads = 0 @@ -382,43 +345,50 @@ async def invoke(*_args, **_kwargs): if name == denied_phase: raise denial return object() + return invoke authority = SimpleNamespace( lock_actor=AsyncMock(side_effect=phase("actor")), revalidate=AsyncMock(side_effect=phase("grant")), ) - materialization = SimpleNamespace( - _storage_scheme="s3", authorize_inspected=AsyncMock(), - ) - workflow = PreparedBundlePreSubmitEvidenceService( - session=SimpleNamespace(), materialization=materialization, - preparation_authorization=authority, - task_contexts=SimpleNamespace(), project_contexts=SimpleNamespace(), - ) - evidence = SimpleNamespace( - lock_task_context=AsyncMock(side_effect=phase("task")), - lock_project_context=AsyncMock(side_effect=phase("project")), + references = SimpleNamespace( + project_id=new_record_id(), guide_version="v1", + source_snapshot_id=new_record_id(), source_snapshot_hash=_sha("1"), + effective_policy_id=new_record_id(), effective_policy_hash=_sha("2"), + pre_submit_policy_id=new_record_id(), pre_submit_policy_bundle_hash=_sha("3"), + ) + task = SimpleNamespace(locked_project_context=references) + async def lock_task(*args): + events.append("task") + return task + project_contexts = SimpleNamespace(lock_locked_policy_context=AsyncMock(side_effect=phase("project"))) + command = PreparedSubmissionBundlePreparationCommand( + session=SimpleNamespace(), authority=authority, + task_contexts=SimpleNamespace(lock_submission_context=AsyncMock(side_effect=lock_task)), + project_contexts=project_contexts, runtime_factory=Mock(), ) - workflow._evidence_service = lambda: evidence - workflow._input = Mock(return_value=object()) - workflow._attempts.reserve = AsyncMock() - with pytest.raises(ArtifactAuthorityDeniedError) as caught: - await workflow.reserve(object(), preparation_request=object()) + await command._lock_authorized_context(_preparation_request(byte_source=object())) assert caught.value is denial - assert events == (["task", "actor"] if denied_phase == "actor" - else ["task", "actor", "project", "grant"]) - materialization.authorize_inspected.assert_not_awaited() - workflow._attempts.reserve.assert_not_awaited() + assert events == ( + ["task", "actor"] if denied_phase == "actor" else ["task", "actor", "project", "grant"] + ) + command._runtime_factory.assert_not_called() @pytest.mark.asyncio -@pytest.mark.parametrize("admission_status,expected_status", ( - ("ready", "ready"), ("stale", "stale"), ("consumed", "conflict"), -)) -async def test_existing_durable_preparation_projects_current_admission_state( - admission_status, expected_status, +@pytest.mark.parametrize( + "admission_status,expected_status", + ( + ("ready", "ready"), + ("stale", "stale"), + ("consumed", "conflict"), + ), +) +async def test_selected_replay_projects_current_admission_state( + admission_status, + expected_status, ) -> None: attempt_id = new_record_id() admission_id = new_record_id() @@ -438,7 +408,10 @@ async def test_existing_durable_preparation_projects_current_admission_state( runtime_factory=Mock(), ) - result = await command._existing_durable_result(new_record_id()) + result = await command._selected_replay_result(SimpleNamespace( + run_id=new_record_id(), status="completed", + terminal=SimpleNamespace(outcome="completed", execution=SimpleNamespace(eligible=True)), + )) assert result == SubmissionBundlePreparationResult( put_attempt_id=attempt_id, @@ -448,9 +421,6 @@ async def test_existing_durable_preparation_projects_current_admission_state( ) - - - @pytest.mark.parametrize( "durable_status", ( @@ -474,7 +444,7 @@ def test_durable_put_result_projects_every_closed_preparation_status( ) -> None: durable = SubmissionBundleDurablePutResult( put_attempt_id=new_record_id(), - pre_submit_evidence_set_id=new_record_id(), + selected_pre_submit_run_id=new_record_id(), operation_identity=_sha("9"), admission_id=None, status=durable_status, @@ -490,588 +460,6 @@ def test_durable_put_result_projects_every_closed_preparation_status( ) -def test_post_byte_locked_context_conflict_maps_to_public_race_code() -> None: - conflict = PreSubmitEvidenceConflict("pre_submit_locked_context_changed") - failure = PreparedSubmissionBundlePreparationCommand._evidence_failure(conflict) - assert isinstance(failure, SubmissionBundlePreparationRejected) - assert str(failure) == "submission_bundle_preparation_context_changed" - -def _capability(prepared, evidence_set_id): - service = PreSubmitEvidenceService( - SimpleNamespace(), - task_contexts=SimpleNamespace(), - project_contexts=SimpleNamespace(), - ) - return service._mint_pass_capability( - evidence_set_id=evidence_set_id, - prepared_generation_id=prepared.generation_id, - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=_sha("7"), - archive_sha256=prepared.commitment.sha256, - semantic_manifest_sha256=_sha("2"), - storage_scheme="s3", - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("observed_confirmation", [False, True]) -async def test_verified_submission_lineage_publishes_one_ready_admission( - observed_confirmation: bool, -) -> None: - attempt_id = str(new_record_id()) - replica_id = str(new_record_id()) - content_id = str(new_record_id()) - job_id = str(new_record_id()) - receipt_id = str(new_record_id()) - evidence_id = str(new_record_id()) - digest = _sha("1") - evidence = SimpleNamespace( - id=evidence_id, - terminal_status="passed", - eligible=True, - archive_sha256=digest, - archive_byte_count=25, - actor_profile_id=str(new_record_id()), - identity_link_id=str(new_record_id()), - project_id=str(new_record_id()), - task_id=str(new_record_id()), - assignment_id=str(new_record_id()), - predecessor_submission_id=None, - predecessor_submission_version=None, - checker_remediation_manifest_id=None, - semantic_manifest_id=str(new_record_id()), - semantic_manifest_sha256=_sha("2"), - guide_id=str(new_record_id()), - guide_version="1", - source_snapshot_id=str(new_record_id()), - source_snapshot_sha256=_sha("3"), - locked_guide_sha256=_sha("4"), - effective_policy_id=str(new_record_id()), - locked_artifact_policy_sha256=_sha("5"), - pre_submit_policy_id=str(new_record_id()), - locked_checker_policy_sha256=_sha("6"), - effective_plan_sha256=_sha("7"), - locked_policy_context_hash=_sha("8"), - ) - attempt = SimpleNamespace( - id=attempt_id, - producer_request_type="submission_bundle", - producer_ref=evidence.actor_profile_id, - project_id=evidence.project_id, - task_id=evidence.task_id, - sha256=digest, - byte_count=25, - media_type="application/zip", - replica_id=replica_id, - receipt_id=None if observed_confirmation else str(new_record_id()), - ) - job = SimpleNamespace( - id=job_id, - originating_put_attempt_id=attempt_id, - replica_id=replica_id, - execution_generation=2, - ) - intent = SimpleNamespace(id=str(new_record_id()), pre_submit_evidence_set_id=evidence_id) - replica = SimpleNamespace( - id=replica_id, - content_id=content_id, - verification_state="verified", - availability_state="available", - integrity_state="valid", - ) - content = SimpleNamespace(id=content_id, sha256=digest, byte_count=25) - verification = SimpleNamespace( - id=receipt_id, - verification_job_id=job_id, - execution_generation=2, - outcome="verified", - observed_sha256=digest, - observed_byte_count=25, - ) - put_receipt = SimpleNamespace(id=str(new_record_id()) if observed_confirmation else attempt.receipt_id) - now = datetime.now(UTC) - session = SimpleNamespace( - scalar=AsyncMock( - side_effect=[ - job, - attempt, - intent, - None, - evidence, - replica, - verification, - content, - put_receipt, - now, - ] - ), - add=Mock(), - flush=AsyncMock(), - ) - - admission = await SubmissionBundleAdmissionPublisher(session).publish_verified( - verification_job_id=job_id, - verification_receipt_id=receipt_id, - ) - - assert admission is not None - assert admission.status == "ready" - assert admission.pre_submit_evidence_set_id == evidence_id - assert admission.artifact_content_id == content_id - assert admission.put_operation_receipt_id == ( - None if observed_confirmation else attempt.receipt_id - ) - assert admission.put_observation_receipt_id == ( - put_receipt.id if observed_confirmation else None - ) - session.add.assert_called_once_with(admission) - - -@pytest.mark.asyncio -async def test_observed_confirmed_write_receipt_is_supported() -> None: - attempt = SimpleNamespace( - id=str(new_record_id()), - receipt_id=None, - sha256=_sha("1"), - byte_count=25, - ) - observation = SimpleNamespace(id=str(new_record_id())) - session = SimpleNamespace(scalar=AsyncMock(return_value=observation)) - - operation_id, observation_id = await SubmissionBundleAdmissionPublisher( - session - )._write_receipt_ids(attempt) - - assert operation_id is None - assert observation_id == observation.id - - -@pytest.mark.asyncio -async def test_retained_guide_verification_cannot_publish_submission_admission() -> None: - job = SimpleNamespace(id=str(new_record_id()), originating_put_attempt_id=str(new_record_id())) - attempt = SimpleNamespace(id=job.originating_put_attempt_id, producer_request_type="guide") - session = SimpleNamespace( - scalar=AsyncMock(side_effect=[job, attempt]), - add=Mock(), - ) - - result = await SubmissionBundleAdmissionPublisher(session).publish_verified( - verification_job_id=job.id, - verification_receipt_id=str(new_record_id()), - ) - - assert result is None - session.add.assert_not_called() - - -@pytest.mark.asyncio -async def test_unverified_or_mismatched_lineage_is_not_publishable() -> None: - digest = _sha("1") - evidence = SimpleNamespace( - id=str(new_record_id()), - terminal_status="failed", - eligible=False, - archive_sha256=digest, - archive_byte_count=25, - actor_profile_id=str(new_record_id()), - project_id=str(new_record_id()), - task_id=str(new_record_id()), - ) - attempt = SimpleNamespace( - id=str(new_record_id()), - sha256=digest, - byte_count=25, - producer_ref=evidence.actor_profile_id, - project_id=evidence.project_id, - task_id=evidence.task_id, - media_type="application/zip", - replica_id=str(new_record_id()), - ) - job = SimpleNamespace( - id=str(new_record_id()), - originating_put_attempt_id=attempt.id, - replica_id=attempt.replica_id, - execution_generation=1, - ) - replica = SimpleNamespace( - id=attempt.replica_id, - content_id=str(new_record_id()), - verification_state="verified", - availability_state="available", - integrity_state="valid", - ) - content = SimpleNamespace(id=replica.content_id, sha256=digest, byte_count=25) - receipt = SimpleNamespace( - verification_job_id=job.id, - execution_generation=1, - outcome="integrity_mismatch", - observed_sha256=digest, - observed_byte_count=25, - ) - - assert not SubmissionBundleAdmissionPublisher._matches_verified_lineage( - evidence, attempt, job, replica, content, receipt - ) - intent = SimpleNamespace(id=str(new_record_id()), pre_submit_evidence_set_id=evidence.id) - session = SimpleNamespace( - scalar=AsyncMock( - side_effect=[job, attempt, intent, None, evidence, replica, receipt, content] - ), - add=Mock(), - flush=AsyncMock(), - ) - attempt.producer_request_type = "submission_bundle" - with pytest.raises( - SubmissionBundleAdmissionPublicationError, - match="verified submission bundle lineage does not match", - ): - await SubmissionBundleAdmissionPublisher(session).publish_verified( - verification_job_id=job.id, - verification_receipt_id=str(new_record_id()), - ) - session.add.assert_not_called() - session.flush.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_durable_put_admits_in_transaction_then_publishes(tmp_path) -> None: - manager, prepared = await _prepared(tmp_path) - evidence_set_id = new_record_id() - state = {"transaction": True} - transaction = SimpleNamespace(is_active=True) - session = SimpleNamespace( - sync_session=SimpleNamespace(get_transaction=lambda: transaction), - in_nested_transaction=lambda: False, - in_transaction=lambda: state["transaction"], - scalar=AsyncMock(return_value=None), - begin=_transaction, - ) - admission_result = ArtifactAdmissionResult( - attempt_id=new_record_id(), - status="prepared", - operation_identity=_sha("3"), - request_digest=_sha("4"), - charge_ids=(new_record_id(),), - replayed=False, - ) - - async def admit(request, **values): - assert state["transaction"] is True - assert type(request) is SubmissionBundleArtifactAdmissionRequest - assert request.pre_submit_evidence_set_id == evidence_set_id - assert request.custody.prepared_generation_id == prepared.generation_id - assert type(request.custody.pass_capability) is PreSubmitPassCapability - assert values["existing_transaction"] is True - assert values["prepared_authorization"] is None - assert values["submission_prepared_authorization"] is service._authorization - return admission_result - - admission = SimpleNamespace(admit=admit) - storage = SimpleNamespace( - execute_committed_put=AsyncMock(return_value="object_confirmed"), - resume_committed_put=AsyncMock(), - ) - service = SubmissionBundleDurablePutService( - task_contexts=object(), - project_contexts=object(), - session=session, - admission=admission, - storage=storage, - authorization=object(), - ) - try: - retained, selected_evidence_id, durable = await service.admit_in_transaction( - SubmissionBundleDurablePutRequest( - prepared_authorization=None, - prepared_artifact=prepared, - pass_capability=_capability(prepared, evidence_set_id), - ) - ) - storage.execute_committed_put.assert_not_awaited() - state["transaction"] = False - result = await service.publish_after_commit( - retained, - selected_evidence_id, - durable, - ) - assert result.pre_submit_evidence_set_id == evidence_set_id - assert result.status == "object_confirmed" - storage.execute_committed_put.assert_awaited_once() - storage.resume_committed_put.assert_not_awaited() - with pytest.raises(RuntimeError, match="prepared artifact is closed"): - _ = prepared.generation_id - finally: - await prepared.close() - manager.close() - - -def test_pass_capability_rejects_direct_construction() -> None: - with pytest.raises(TypeError, match="can only be created by pre-submit evidence"): - PreSubmitPassCapability( - evidence_set_id=new_record_id(), - prepared_generation_id=new_record_id(), - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=_sha("7"), - archive_sha256=_sha("1"), - semantic_manifest_sha256=_sha("2"), - storage_scheme="s3", - ) - - -def test_pass_capability_rejects_unregistered_service_owner() -> None: - with pytest.raises(TypeError, match="can only be created by pre-submit evidence"): - PreSubmitPassCapability._from_evidence_service( - owner=object.__new__(PreSubmitEvidenceService), - binding=object(), - evidence_set_id=new_record_id(), - prepared_generation_id=new_record_id(), - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=_sha("7"), - archive_sha256=_sha("1"), - semantic_manifest_sha256=_sha("2"), - storage_scheme="s3", - ) - - -def test_submission_custody_rejects_direct_construction() -> None: - with pytest.raises(TypeError, match="requires live prepared work"): - SubmissionBundlePreparedCustody() - - -def test_submission_request_rejects_evidence_without_live_custody() -> None: - request = SubmissionBundleArtifactAdmissionRequest( - pre_submit_evidence_set_id=new_record_id(), - custody=object(), - replay_durable_intent_id=None, - ) - with pytest.raises(TypeError, match="prepared custody is unavailable"): - _ = request.source - - -def test_replay_lineage_includes_policy_catalogue_and_manifest_identity() -> None: - values = { - "actor_profile_id": str(new_record_id()), - "identity_link_id": str(new_record_id()), - "project_id": str(new_record_id()), - "task_id": str(new_record_id()), - "assignment_id": str(new_record_id()), - "predecessor_submission_id": None, - "predecessor_submission_version": None, - "checker_remediation_manifest_id": None, - "archive_sha256": _sha("1"), - "archive_byte_count": 10, - "semantic_manifest_id": str(new_record_id()), - "semantic_manifest_sha256": _sha("2"), - "guide_id": str(new_record_id()), - "guide_version": "1", - "source_snapshot_id": str(new_record_id()), - "source_snapshot_sha256": _sha("3"), - "locked_guide_sha256": _sha("4"), - "effective_policy_id": str(new_record_id()), - "locked_artifact_policy_sha256": _sha("5"), - "pre_submit_policy_id": str(new_record_id()), - "locked_checker_policy_sha256": _sha("6"), - "effective_plan_sha256": _sha("7"), - "catalogue_id": "workstream.default", - "catalogue_version": "1", - "catalogue_manifest_sha256": _sha("8"), - "storage_scheme": "s3", - "terminal_status": "passed", - "eligible": True, - "result_count": 2, - "result_manifest_sha256": _sha("9"), - } - original = SimpleNamespace(**values) - for field, changed in { - "semantic_manifest_id": str(new_record_id()), - "locked_guide_sha256": _sha("a"), - "effective_policy_id": str(new_record_id()), - "pre_submit_policy_id": str(new_record_id()), - "catalogue_id": "other.catalogue", - "catalogue_version": "2", - "catalogue_manifest_sha256": _sha("b"), - }.items(): - drifted = SimpleNamespace(**{**values, field: changed}) - assert ArtifactAdmissionService._submission_replay_lineage( - original - ) != ArtifactAdmissionService._submission_replay_lineage(drifted) - - -@pytest.mark.asyncio -async def test_concurrent_pass_capability_consumption_has_one_winner(tmp_path) -> None: - manager, prepared = await _prepared(tmp_path) - capability = _capability(prepared, new_record_id()) - - def consume() -> bool: - try: - capability.consume( - prepared_generation_id=prepared.generation_id, - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=capability.effective_plan_sha256, - archive_sha256=prepared.commitment.sha256, - semantic_manifest_sha256=capability.semantic_manifest_sha256, - storage_scheme=capability.storage_scheme, - ) - except PreSubmitEvidenceConflict: - return False - return True - - try: - outcomes = await asyncio.gather( - asyncio.to_thread(consume), - asyncio.to_thread(consume), - ) - assert sorted(outcomes) == [False, True] - finally: - await prepared.close() - manager.close() - - -@pytest.mark.asyncio -async def test_fresh_checked_custody_resumes_existing_committed_intent(tmp_path) -> None: - manager, prepared = await _prepared(tmp_path) - evidence_set_id = new_record_id() - replay_intent_id = new_record_id() - state = {"transaction": True} - admission_result = ArtifactAdmissionResult( - attempt_id=new_record_id(), - status="absent_replay_required", - operation_identity=_sha("3"), - request_digest=_sha("4"), - charge_ids=(new_record_id(),), - replayed=True, - ) - - async def admit(request, **values): - assert request.replay_durable_intent_id == replay_intent_id - return admission_result - - storage = SimpleNamespace( - execute_committed_put=AsyncMock(), - resume_committed_put=AsyncMock(return_value="object_confirmed"), - ) - service = SubmissionBundleDurablePutService( - task_contexts=object(), - project_contexts=object(), - session=SimpleNamespace( - sync_session=SimpleNamespace(get_transaction=lambda: SimpleNamespace(is_active=True)), - in_nested_transaction=lambda: False, - in_transaction=lambda: state["transaction"], - scalar=AsyncMock(return_value=None), - begin=_transaction, - ), - admission=SimpleNamespace(admit=admit), - storage=storage, - authorization=object(), - ) - try: - retained, selected_evidence_id, durable = await service.admit_in_transaction( - SubmissionBundleDurablePutRequest( - prepared_authorization=object.__new__(PreparedAuthorizationHandle), - prepared_artifact=prepared, - pass_capability=_capability(prepared, evidence_set_id), - replay_durable_intent_id=replay_intent_id, - ) - ) - state["transaction"] = False - result = await service.publish_after_commit( - retained, - selected_evidence_id, - durable, - ) - assert result.replayed is True - storage.resume_committed_put.assert_awaited_once() - storage.execute_committed_put.assert_not_awaited() - finally: - await prepared.close() - manager.close() - - -@pytest.mark.asyncio -async def test_object_confirmed_replay_does_not_reclaim_provider_work(tmp_path) -> None: - manager, prepared = await _prepared(tmp_path) - storage = SimpleNamespace( - execute_committed_put=AsyncMock(), - resume_committed_put=AsyncMock(), - ) - service = SubmissionBundleDurablePutService( - task_contexts=object(), - project_contexts=object(), - session=SimpleNamespace( - in_transaction=lambda: False, - scalar=AsyncMock(return_value=None), - begin=_transaction, - ), - admission=SimpleNamespace(), - storage=storage, - authorization=object(), - ) - try: - result = await service.publish_after_commit( - prepared, - new_record_id(), - ArtifactAdmissionResult( - attempt_id=new_record_id(), - status="object_confirmed", - operation_identity=_sha("3"), - request_digest=_sha("4"), - charge_ids=(new_record_id(),), - replayed=True, - ), - ) - assert result.status == "object_confirmed" - storage.execute_committed_put.assert_not_awaited() - storage.resume_committed_put.assert_not_awaited() - finally: - await prepared.close() - manager.close() - - -@pytest.mark.asyncio -async def test_durable_put_rejects_capability_replay_before_admission(tmp_path) -> None: - manager, prepared = await _prepared(tmp_path) - capability = _capability(prepared, new_record_id()) - capability.consume( - prepared_generation_id=prepared.generation_id, - predecessor_submission_id=None, - checker_remediation_manifest_id=None, - effective_plan_sha256=capability.effective_plan_sha256, - archive_sha256=prepared.commitment.sha256, - semantic_manifest_sha256=capability.semantic_manifest_sha256, - storage_scheme=capability.storage_scheme, - ) - admission = SimpleNamespace(admit=AsyncMock()) - service = SubmissionBundleDurablePutService( - task_contexts=object(), - project_contexts=object(), - session=SimpleNamespace( - sync_session=SimpleNamespace(get_transaction=lambda: SimpleNamespace(is_active=True)), - in_nested_transaction=lambda: False, - in_transaction=lambda: True, - ), - admission=admission, - storage=SimpleNamespace(), - authorization=object(), - ) - try: - with pytest.raises(PreSubmitEvidenceConflict): - await service.admit_in_transaction( - SubmissionBundleDurablePutRequest( - prepared_authorization=object.__new__(PreparedAuthorizationHandle), - prepared_artifact=prepared, - pass_capability=capability, - ) - ) - admission.admit.assert_not_awaited() - finally: - await prepared.close() - manager.close() - - @pytest.mark.asyncio async def test_deny_submission_authority_fails_closed() -> None: with pytest.raises(ArtifactAuthorityDeniedError): diff --git a/backend/tests/test_submission_bundle_preparation_recovery.py b/backend/tests/test_submission_bundle_preparation_recovery.py index 1c7bac62c..768978d53 100644 --- a/backend/tests/test_submission_bundle_preparation_recovery.py +++ b/backend/tests/test_submission_bundle_preparation_recovery.py @@ -1,21 +1,15 @@ """Command recovery distinguishes checked failure, unavailable custody and authority.""" -from contextlib import asynccontextmanager -from dataclasses import asdict, replace -import json from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import AsyncMock from app.core.identifiers import new_record_id import pytest from httpx import ASGITransport, AsyncClient -from app.adapters.artifacts import CheckerPhaseService -from tests.checkers.execution.support import forbidden_post_submission from fastapi import HTTPException from starlette.requests import Request -import app.modules.artifacts.submission_admission as submission_admission_module from app.api.routes.artifact_submissions import prepare_submission_bundle from app.adapters.artifacts import ( get_submission_bundle_preparation_actor, @@ -24,56 +18,37 @@ from app.main import create_app from app.modules.artifacts.api import ( SubmissionBundlePreparationCheckFailed, - SubmissionBundlePreparationRequest, SubmissionBundlePreparationRejected, SubmissionBundlePreparationUnavailable, SubmissionBundlePreparationInfrastructureUnavailable, SubmissionBundlePreparationResult, + SubmissionBundlePreparationStatus, ) -from app.modules.checkers.api import ( - PreSubmissionExecutionEntryFacts, - PreSubmissionExecutionFacts, +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, WORKSTREAM_DEFAULT_CHECK_IDS, + WorkstreamDefaultCheckerResult, ) +from app.modules.checkers.api.selected_pre_submit import SelectedPreSubmitExecutionFacts from app.modules.authorization.api import ActorIdentityFacts, ActorKind -from app.modules.artifacts.pre_submit_evidence import ( - PreSubmitEvidenceConflict, PreSubmitEvidencePersistenceResult, -) -from app.modules.artifacts.submission_admission import ( - PreparedSubmissionBundlePreparationCommand, SubmissionBundlePreparationRuntime, -) -from tests.artifact_store_helpers import artifact_byte_stream -from tests.test_submission_bundle_admission import _actor, _transaction +def _actor(): + return ActorIdentityFacts(new_record_id(), new_record_id(), ActorKind.HUMAN) + -def _feedback_facts(*, eligible: bool = False) -> PreSubmissionExecutionFacts: - """Build exact canonical facts without defining another feedback contract.""" - statuses = (("passed", None, "passed"),) if eligible else ( - ("warning", None, "quality_signal_warning"), - ("failed", "pre_submission_required_file_missing", "required_file_missing"), - ("dependency_not_run", None, "dependency_not_run"), +def _feedback_facts(*, eligible: bool = False) -> SelectedPreSubmitExecutionFacts: + """Use the sole selected default result shape for sanitized HTTP feedback.""" + checks = tuple({ + "check_id": check_id, + "status": ("passed" if eligible or index < 2 else "failed" if index == 2 else "not_run"), + "failure_code": "high_confidence_secret_detected" if not eligible and index == 2 else None, + "finding_count": 1 if not eligible and index == 2 else 0, + } for index, check_id in enumerate(WORKSTREAM_DEFAULT_CHECK_IDS)) + default = WorkstreamDefaultCheckerResult( + behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + eligible=eligible, checks=checks, + warnings=({"code": "submission_summary_missing"}, {"code": "submission_attestation_missing"}), ) - return PreSubmissionExecutionFacts( - plan_sha256="sha256:" + "1" * 64, - eligible=eligible, - entries=tuple( - PreSubmissionExecutionEntryFacts( - dispatch_authority="workstream.pre_submission_checker_catalogue", - definition_id=f"workstream.test.{index}", - definition_version="1.0.0", - public_name=f"Test check {index}", - policy_source="workstream_default", - effective_plan_sha256="sha256:" + "1" * 64, - rule_instance_id=None, - locked_policy_sha256="sha256:" + "2" * 64, - phase="default_policy", - order=index, - classification="advisory" if status == "warning" else "blocking", - severity="warning" if status == "warning" else "blocking", - checker_execution_status=status, - failure_code=failure_code, - message_code=message_code, - metadata=(("finding_count", index + 1),) if not eligible else (), - ) - for index, (status, failure_code, message_code) in enumerate(statuses) - ), + return SelectedPreSubmitExecutionFacts( + policy_sha256="sha256:" + "1" * 64, workstream_default=default, + required_external_count=0, external_members=(), eligible=eligible, ) @@ -117,7 +92,13 @@ async def test_mounted_preparation_returns_exact_ordered_bounded_feedback() -> N assert payload["error"]["details"] == { "status": "failed", "eligible_to_submit": False, - "results": json.loads(json.dumps([asdict(entry) for entry in facts.entries])), + "results": [ + {"source": "workstream_default", **check.model_dump(mode="json")} + for check in facts.workstream_default.checks + ] + [ + {"source": "workstream_default", "status": "warning", "warning_code": warning.code} + for warning in facts.workstream_default.warnings + ], } rendered = response.text assert all(value not in rendered for value in ("/tmp/", "s3://", "task.toml")) @@ -167,21 +148,22 @@ async def test_mounted_preparation_preserves_non_feedback_error_boundaries( def test_blocked_feedback_rejects_noncanonical_or_non_false_facts(eligible) -> None: with pytest.raises(TypeError, match="feedback facts are invalid"): SubmissionBundlePreparationCheckFailed(object()) # type: ignore[arg-type] + from dataclasses import fields + + original = _feedback_facts() + forged = object.__new__(SelectedPreSubmitExecutionFacts) + for field in fields(original): + object.__setattr__(forged, field.name, getattr(original, field.name)) + object.__setattr__(forged, "eligible", eligible) with pytest.raises(ValueError, match="require eligible false"): - SubmissionBundlePreparationCheckFailed( - replace(_feedback_facts(), eligible=eligible) # type: ignore[arg-type] - ) + SubmissionBundlePreparationCheckFailed(forged) @pytest.mark.asyncio @pytest.mark.parametrize("error,status_code,detail", ( (SubmissionBundlePreparationRejected("submission_bundle_preparation_context_changed"), 409,"submission_bundle_preparation_context_changed"), - (PreparedSubmissionBundlePreparationCommand._evidence_failure( - PreSubmitEvidenceConflict("pre_submit_attempt_result_unavailable")), - 503,"pre_submission_checked_custody_unavailable"), - (PreparedSubmissionBundlePreparationCommand._evidence_failure( - PreSubmitEvidenceConflict("pre_submit_attempt_member_invalid")), + (SubmissionBundlePreparationInfrastructureUnavailable("pre_submission_checked_custody_unavailable"), 503,"pre_submission_checked_custody_unavailable"), (SubmissionBundlePreparationInfrastructureUnavailable("pre_submission_attempt_outcome_unresolved"), 503,"pre_submission_attempt_outcome_unresolved"), @@ -221,170 +203,25 @@ async def test_hidden_preparation_maps_context_custody_and_authority_distinctly( assert failure.value.detail == detail - -def _preparation_replay_runtime(prepare_bytes, evidence_id, *, eligible): - """Supply bounded ART outcome doubles for command routing proof.""" - evidence = SimpleNamespace( - reserve=AsyncMock(return_value=object()), - execute_reserved=AsyncMock(return_value=PreSubmitEvidencePersistenceResult( - evidence=SimpleNamespace(evidence_set_id=evidence_id), - pass_capability=None, failure_audit=None, - execution=SimpleNamespace( - eligible=eligible, - checker_facts=_feedback_facts(eligible=eligible), - ), - )), - ) - checker_service = CheckerPhaseService( - pre_submission=evidence, post_submission=forbidden_post_submission(), - ) - checker_service.evaluate_pre_submission = AsyncMock(wraps=checker_service.evaluate_pre_submission) - from app.modules.checkers.api import PostSubmissionEvaluationContent - from tests.checkers.post_submit.support import request as evaluation_request - content = evaluation_request().model_dump(include=set(PostSubmissionEvaluationContent.model_fields)) - return SubmissionBundlePreparationRuntime( - evaluation_content=Mock(return_value=PostSubmissionEvaluationContent(**content)), - checker_service=checker_service, - preparation=SimpleNamespace(prepare=AsyncMock(side_effect=prepare_bytes)), - inspector=object(), - catalogue=object(), - materialization=SimpleNamespace(prepare_authorization=AsyncMock(return_value=object())), - evidence=evidence, - durable_put=object(), - ) - - -async def _assert_blocked_feedback(command, request, runtime) -> None: - with pytest.raises( - SubmissionBundlePreparationCheckFailed, - match="pre_submission_checker_failed", - ) as failure: - await command.prepare(request) - assert ( - failure.value.facts - == runtime.evidence.execute_reserved.return_value.execution.checker_facts - ) - - @pytest.mark.asyncio -@pytest.mark.parametrize("outcome", ("completed", "blocked", "unresolved", "no_continuation", "corrupt_evidence")) -async def test_hidden_preparation_replays_persisted_checked_custody(monkeypatch, outcome) -> None: - actor_id, task_id, assignment_id = new_record_id(), new_record_id(), new_record_id() - evidence_id = new_record_id() - expected = SubmissionBundlePreparationResult( - put_attempt_id=new_record_id(), - admission_id=new_record_id(), - submission_bundle_preparation_status="ready", - replayed=True, - ) - locked = SimpleNamespace(effective_policy_id=new_record_id(), pre_submit_policy_id=new_record_id()) - prepared = SimpleNamespace( - commitment=SimpleNamespace(sha256="sha256:" + "a" * 64), - inspect=AsyncMock(return_value=object()), - close=AsyncMock(), - ) - events: list[str] = [] - - async def prepare_bytes(*_args, **_kwargs): - events.append("prepare_bytes") - return prepared - - async def revalidate(**_kwargs): - events.append("revalidate") - - runtime = _preparation_replay_runtime(prepare_bytes, evidence_id, eligible=outcome != "blocked") - - @asynccontextmanager - async def runtime_factory(): - yield runtime - - monkeypatch.setattr( - submission_admission_module, - "build_submission_manifest", - Mock(return_value=SimpleNamespace(file_facts=Mock(return_value=()))), - ) - monkeypatch.setattr( - submission_admission_module, - "evaluate_submission_change", - Mock(return_value=object()), - ) - project_id = new_record_id() - authority = SimpleNamespace( - preflight=AsyncMock(), revalidate=AsyncMock(side_effect=revalidate), close=Mock() - ) - command = PreparedSubmissionBundlePreparationCommand( - session=SimpleNamespace(begin=_transaction), - authority=authority, - task_contexts=SimpleNamespace(), - project_contexts=SimpleNamespace(), - runtime_factory=runtime_factory, - ) - command._lock_context = AsyncMock( - return_value=( - SimpleNamespace( - predecessor=None, - checker_remediation_manifest_id=None, - locked_project_context=SimpleNamespace(project_id=project_id), - ), - locked, - ) - ) - command._compile_plan = Mock(return_value=object()) - command._load_predecessor = AsyncMock(return_value=None) - command._existing_durable_result = AsyncMock(return_value=expected) - - request = SubmissionBundlePreparationRequest( - actor=ActorIdentityFacts( - actor_profile_id=actor_id, - identity_link_id=new_record_id(), - actor_kind=ActorKind.HUMAN, - ), - request_id=new_record_id(), - correlation_id=new_record_id(), - task_id=task_id, - assignment_id=assignment_id, - predecessor_submission_id=None, - idempotency_key=new_record_id(), - summary="summary", - contributor_attestation="attestation", - media_type="application/zip", - byte_source=artifact_byte_stream(b"PK\x03\x04replay"), - ) - if outcome in {"unresolved", "corrupt_evidence"}: - runtime.evidence.reserve.side_effect = PreSubmitEvidenceConflict( - "pre_submit_attempt_outcome_unresolved" if outcome == "unresolved" - else "pre_submit_attempt_result_digest_invalid" - ) - if outcome == "no_continuation": - command._existing_durable_result.return_value = None - if outcome == "completed": - runtime.evidence.reserve.return_value = runtime.evidence.execute_reserved.return_value - assert await command.prepare(request) == expected - elif outcome == "blocked": - await _assert_blocked_feedback(command, request, runtime) - command._existing_durable_result.assert_not_awaited() - else: - code = ("pre_submission_attempt_outcome_unresolved" if outcome == "unresolved" - else "pre_submission_checked_custody_unavailable") - with pytest.raises(SubmissionBundlePreparationInfrastructureUnavailable, match=code): - await command.prepare(request) - runtime.preparation.prepare.assert_awaited_once() - assert authority.revalidate.await_args_list == [ - call(request=request, project_id=project_id), - call(request=request, project_id=project_id), - ] - assert events[:2] == ["revalidate", "prepare_bytes"] - runtime.evidence.reserve.assert_awaited_once() - if outcome in {"unresolved", "corrupt_evidence"}: - runtime.checker_service.evaluate_pre_submission.assert_not_awaited() - else: - runtime.checker_service.evaluate_pre_submission.assert_awaited_once() - phase_request, selection = runtime.checker_service.evaluate_pre_submission.await_args.args - assert phase_request.prepared_authorization is None - assert selection is runtime.evidence.reserve.return_value - if outcome in {"completed", "unresolved", "corrupt_evidence"}: - runtime.evidence.execute_reserved.assert_not_awaited() - else: - runtime.evidence.execute_reserved.assert_awaited_once() - prepared.close.assert_awaited_once() - authority.close.assert_called_once_with() +async def test_hidden_preparation_treats_missing_optional_packet_text_as_warnings() -> None: + """Missing summary and attestation reach PRE as empty warning-only values.""" + command = SimpleNamespace(prepare=AsyncMock(return_value=SubmissionBundlePreparationResult( + put_attempt_id=new_record_id(), admission_id=None, + submission_bundle_preparation_status=SubmissionBundlePreparationStatus.PREPARED, + replayed=False, + ))) + request = Request({ + "type": "http", "method": "POST", "path": "/", + "headers": [(b"content-type", b"application/zip")], + }) + + await prepare_submission_bundle( + task_id=str(new_record_id()), request=request, actor=_actor(), command=command, + assignment_id=str(new_record_id()), idempotency_key=str(new_record_id()), + summary=None, contributor_attestation=None, + ) + + prepared = command.prepare.await_args.args[0] + assert prepared.summary == "" + assert prepared.contributor_attestation == "" diff --git a/backend/tests/test_submission_change_gate.py b/backend/tests/test_submission_change_gate.py index 61ba4eb8f..48f7c295c 100644 --- a/backend/tests/test_submission_change_gate.py +++ b/backend/tests/test_submission_change_gate.py @@ -16,6 +16,7 @@ SubmissionChangeFailureCode, SubmissionChangeOutcome, SubmissionChangeRejectedError, + bind_submission_change, build_submission_manifest, evaluate_submission_change, ) @@ -117,6 +118,16 @@ def test_exact_archive_and_semantically_unchanged_reject_distinctly() -> None: current_predecessor=semantic_predecessor, ) + bound = bind_submission_change( + commitment=_commitment(), + manifest=manifest, + predecessor=semantic_predecessor, + predecessor_exists=True, + current_predecessor=semantic_predecessor, + ) + assert bound.outcome is SubmissionChangeOutcome.UNCHANGED + assert bound.predecessor is semantic_predecessor + def test_predecessor_advancement_rejects_stale_selector_before_comparison() -> None: _rejects( diff --git a/backend/tests/test_submission_composition.py b/backend/tests/test_submission_composition.py index bf5a324cb..f2ce5fcce 100644 --- a/backend/tests/test_submission_composition.py +++ b/backend/tests/test_submission_composition.py @@ -251,7 +251,7 @@ async def add_submission(self, submission): persisted.append(submission) service._repository = Repository() # This test isolates ART result validation, not policy validation behavior. service._contexts = SimpleNamespace(_load_locked_task_context=AsyncMock()) - with pytest.raises(RuntimeError, match="exact binding facts"): + with pytest.raises(SubmissionCreationUnavailable, match="submission creation is unavailable"): await service.create(request) assert events == ["authorize", "prepare", "art"] assert len(persisted) == 1 @@ -331,9 +331,10 @@ def _consumption_values(*, binding=False, remediation=False): ) if binding: values.update({name: str(new_record_id()) for name in ( - "evidence_set_id", "project_id", "guide_id", "semantic_manifest_id", "content_id", + "selected_pre_submit_run_id", "project_id", "guide_id", "semantic_manifest_id", "content_id", )}) values.update( + evidence_set_id=None, resource_type="submission_binding", resource_id=values["admission_id"], locked_policy_context_hash="sha256:" + "2" * 64, semantic_manifest_sha256="sha256:" + "3" * 64, diff --git a/backend/tests/test_submission_evaluation_capacity.py b/backend/tests/test_submission_evaluation_capacity.py deleted file mode 100644 index 43cd2da2a..000000000 --- a/backend/tests/test_submission_evaluation_capacity.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Real inspected ZIP capacity and exact locked input projection before durable admission.""" - -from dataclasses import replace -from io import BytesIO -from unittest.mock import patch -from zipfile import ZipFile - -import json -import pytest -from sqlalchemy import func, select - -from app.adapters.artifacts.local import LocalStorageAdapter -from app.adapters.checkers import submission_evaluation_content -from app.core.identifiers import new_record_id -from app.modules.artifacts.api import SubmissionBundleFile, SubmissionBundlePreparationRejected -from app.modules.artifacts.models import ( - ArtifactPutAttempt, PreSubmitEvidenceSet, PreSubmitExecutionAttempt, - SubmissionBundleAdmission, SubmissionBundleDurableIntent, -) -from app.modules.artifacts.submission_materialization import PreparedBundlePreSubmitEvidenceService -from app.modules.checkers.api import SubmissionPacketView -from app.modules.checkers.api.post_submit import make_post_submit_request -from app.modules.checkers.runner import check_acceptance_criteria_present, check_evidence_present -from app.modules.checkers.post_submit_implementations import detached_checker_context -from app.modules.projects.api import ProjectLockedPolicyContextRequest -from app.modules.projects.api.locked_policy import CanonicalJsonObject -from app.modules.projects.api.policy_lineage import ReviewPolicySemantics, policy_digest -from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository -from app.modules.tasks.api import TaskSubmissionContextRequest -from app.modules.tasks.repository import TaskRepository -from tests.submission_capacity_fixtures import capacity_fixture -from tests.pre_submit_test_helpers import approved_pre_submit_fixture -from tests.tasks.submission_lineage_support import _verified_admission -from tests.test_default_pre_submit_execution import _archive, _bytes -from tests.checkers.post_submit.support import request as evaluation_request - - -async def test_real_zip_capacity_rejects_before_effects_and_valid_control_passes( - isolated_database_env, tmp_path, -): - async with capacity_fixture(isolated_database_env, tmp_path) as h: - base = _archive(evidence_path=h.policy["evidence_path"]) - many = BytesIO(base) - with ZipFile(many, "a") as archive: - for index in range(1023): - archive.writestr(f"f{index:04d}", b"x") - # Exactly 1001 characters, each component portable and within ART limits. - path = "/".join(["a" * 200] * 4 + ["b" * 197]) - assert len(path) == 1001 - long_path = _archive(evidence_path=h.policy["evidence_path"], extra_path=path) - models = (ArtifactPutAttempt, PreSubmitExecutionAttempt, PreSubmitEvidenceSet, - SubmissionBundleDurableIntent, SubmissionBundleAdmission) - async with h.factory() as session: - # Guide setup already uploaded its own documents; retain that custody. - before = {model: await session.scalar(select(func.count()).select_from(model)) for model in models} - assert await session.scalar(select(func.count()).select_from(ArtifactPutAttempt).where( - ArtifactPutAttempt.task_id == str(h.request.task_id))) == 0 - for payload in (many.getvalue(), long_path): - key = new_record_id() - for _ in range(2): - with patch.object(PreparedBundlePreSubmitEvidenceService, "reserve", autospec=True, - side_effect=PreparedBundlePreSubmitEvidenceService.reserve) as reserve, \ - patch.object(LocalStorageAdapter, "put", autospec=True, side_effect=LocalStorageAdapter.put) as put: - with pytest.raises(SubmissionBundlePreparationRejected, match="^submission_evaluation_content_invalid$"): - await _verified_admission(h.factory, h.store, h.namespace, h.settings, h.context, - replace(h.request, idempotency_key=key, byte_source=_bytes(payload))) - reserve.assert_not_called() - put.assert_not_called() - async with h.factory() as session: - for model in models: - assert await session.scalar(select(func.count()).select_from(model)) == before[model], model.__tablename__ - for folder in ("files", "workspaces"): - assert list((h.settings.artifact_scratch_root / folder).iterdir()) == [] - admission = await _verified_admission(h.factory, h.store, h.namespace, h.settings, h.context, - replace(h.request, byte_source=_bytes(base))) - async with h.factory() as session: - row = await session.get(SubmissionBundleAdmission, str(admission)) - assert row.status == "ready" - - -async def _locked_facts(h): - async with h.factory.begin() as session: - task, source = await TaskRepository(session).select_submission_context(TaskSubmissionContextRequest( - task_id=h.request.task_id, assignment_id=h.request.assignment_id, - contributor_id=h.context.actor_profile_id, predecessor_submission_id=None, - )) - assert source is None - refs = task.locked_project_context - project = await ProjectLockedPolicyRepository(session).lock_locked_policy_context(ProjectLockedPolicyContextRequest( - project_id=refs.project_id, guide_version=refs.guide_version, - source_snapshot_id=refs.source_snapshot_id, source_snapshot_hash=refs.source_snapshot_hash, - effective_policy_id=refs.effective_policy_id, effective_policy_hash=refs.effective_policy_hash, - pre_submit_policy_id=refs.pre_submit_policy_id, pre_submit_policy_bundle_hash=refs.pre_submit_policy_bundle_hash, - )) - return task, project - - -async def test_projection_exact_files_criteria_and_all_locked_stamps(isolated_database_env, tmp_path): - async with capacity_fixture(isolated_database_env, tmp_path) as h: - task, project = await _locked_facts(h) - packet = SubmissionPacketView(summary=h.request.summary, contributor_attestation=h.request.contributor_attestation) - evidence_file = SubmissionBundleFile(normalized_path=h.policy["evidence_path"], sha256="sha256:" + "1" * 64, byte_count=19) - other_file = SubmissionBundleFile(normalized_path="evidence/other", sha256="sha256:" + "2" * 64, byte_count=23) - project_hash = "sha256:" + "3" * 64 - def project_content(task_facts=task, files=(evidence_file, other_file)): - return submission_evaluation_content(task_facts, project, packet, files, project_hash) - content = project_content() - assert content.structural_input.criteria == task.acceptance_criteria - assert content.structural_input.package_hash == project_hash - assert [(m.artifact, m.hash, m.size_bytes) for m in content.structural_input.manifest] == [ - (f.normalized_path, f.sha256, f.byte_count) for f in (evidence_file, other_file)] - assert [(e.key, e.uri, e.hash) for e in content.structural_input.evidence] == [ - (json.loads(project.effective_policy.value)["required_evidence"][0]["key"], - evidence_file.normalized_path, evidence_file.sha256)] - # Detached false-policy facts prove the same capacity rules, not live activation. - for required in (True, False): - semantics = ReviewPolicySemantics(**{**json.loads(project.review_policy.value), "human_review_required": required}) - digest = policy_digest("review", semantics) - receipt = project.activation_receipt - selection = type(receipt.command.review)(**{**receipt.command.review.model_dump(), "policy_hash": digest}) - command = type(receipt.command)(**{**receipt.command.model_dump(), "review": selection}) - updated_receipt = type(receipt)(**{**receipt.model_dump(), "command": command}) - updated_project = replace(project, activation_receipt=updated_receipt, review_semantics_format="v2", - review_policy=CanonicalJsonObject.from_mapping(semantics.model_dump(mode="json"))) - updated_policy = type(task.locked_policy)(**{**task.locked_policy.model_dump(), "locked_review_policy_hash": digest}) - updated_task = replace(task, locked_policy=updated_policy) - projected = submission_evaluation_content(updated_task, updated_project, packet, (evidence_file,), project_hash) - assert projected.expected_context.review_hash == digest - with pytest.raises(ValueError, match="1024"): - submission_evaluation_content(updated_task, updated_project, packet, (evidence_file,) * 1025, project_hash) - base = evaluation_request() - envelope = base.model_dump(exclude=set(type(content).model_fields) | {"request_sha256"}) - for files in ((), (other_file,)): - missing = project_content(files=files) - assert missing.structural_input.evidence == () - outcome = await check_evidence_present(detached_checker_context(make_post_submit_request(**envelope, **missing.model_dump()))) - assert outcome.status == "failed" - for criteria in (None, ""): - empty = project_content(replace(task, acceptance_criteria=criteria)) - assert empty.structural_input.criteria == "" - outcome = await check_acceptance_criteria_present(detached_checker_context(make_post_submit_request(**envelope, **empty.model_dump()))) - assert outcome.status == "failed" - with pytest.raises(ValueError, match="65536"): - project_content(replace(task, acceptance_criteria="x" * 65537)) - # Every selected downstream identity/generation/hash is varied independently. - for field in type(task.locked_policy).model_fields: - if not field.startswith(("locked_post_", "locked_review_", "locked_revision_")): - continue - value = getattr(task.locked_policy, field) - changed = new_record_id() if field.endswith("_id") else value + 1 if type(value) is int else "sha256:" + "f" * 64 if field.endswith("hash") else "different" - altered = type(task.locked_policy)(**{**task.locked_policy.model_dump(), field: changed}) - with pytest.raises(ValueError, match="task submission policy differs"): - project_content(replace(task, locked_policy=altered)) - foreign_plan, _ = await approved_pre_submit_fixture(h.factory, h.namespace, guide_version="v1") - async with h.factory.begin() as session: - foreign_project = await ProjectLockedPolicyRepository(session).lock_active_policy_context(foreign_plan.lineage.project_id) - with pytest.raises(ValueError, match="task submission project differs"): - submission_evaluation_content(task, foreign_project, packet, (evidence_file,), project_hash) diff --git a/backend/tests/test_submission_predecessor_custody.py b/backend/tests/test_submission_predecessor_custody.py index 0bc4b5731..e698417d0 100644 --- a/backend/tests/test_submission_predecessor_custody.py +++ b/backend/tests/test_submission_predecessor_custody.py @@ -12,7 +12,7 @@ import pytest from sqlalchemy import text -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import DBAPIError, IntegrityError from starlette.requests import Request from app.api.deps.authorization import compose_hidden_submission_creation_command @@ -23,6 +23,7 @@ from app.core.identifiers import new_record_id from app.core.hashing import canonical_json_hash from app.modules.actors.api.service_identities import ServiceIdentity +from app.interfaces.artifacts import ArtifactStoreUnavailableError from app.modules.artifacts.api import ( SubmissionBundlePreparationCheckFailed, SubmissionBundlePreparationRejected, @@ -32,20 +33,15 @@ PreparedArtifactInternalAuthority, PreparedSubmissionBundlePreparationAuthorization, ) +from app.modules.checkers.models import CheckerRun, SelectedPreSubmitRun from app.modules.artifacts.models import ( - PreSubmitEvidenceSet, SubmissionBundleAdmission, ) from app.modules.artifacts.submission_admission import ( PreparedSubmissionBundlePreparationCommand, ) -from app.modules.artifacts.submission_manifest import ( - SubmissionChangeFailureCode, - SubmissionChangeRejectedError, -) from app.modules.authorization.api import ActorIdentityFacts, ActorKind from app.modules.checkers.api.execution import CheckerExecutionUnavailable -from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue from app.modules.tasks.api import SubmissionCreationRequest from app.modules.tasks.api.submission_context import ( TaskSubmissionContextRequest, @@ -57,30 +53,17 @@ from app.adapters.tasks import task_submission_context_port from tests.auth_concurrency_support import wait_for_named_database_lock from tests.artifact_store_helpers import artifact_byte_stream -from tests.checkers.execution.support import reserve -from tests.checkers.execution.completion_fixture import completed_evidence +from tests.checkers.execution.support import live_executor, reserve +from tests.post_submit_materialization_helpers import material_fixture from tests.tasks.post_submit_routing.remediation_support import ( no_economic_effects, remediation_source, ) from tests.tasks.post_submit_routing.support import next_request, revision_archive from tests.tasks.submission_lineage_support import _verified_admission - - -_PREPARATION_TABLES = ( - "pre_submit_execution_attempts", - "pre_submit_evidence_sets", - "pre_submit_evidence_results", - "submission_bundle_durable_intents", - "artifact_put_attempts", - "artifact_put_attempt_charges", - "artifact_contents", - "artifact_replicas", - "artifact_operation_receipts", - "artifact_put_observation_receipts", - "artifact_verification_jobs", - "artifact_verification_receipts", - "submission_bundle_admissions", +from tests.selected_predecessor_fixtures import ( + _snapshot, _secret_archive, _assert_default_failure, + _queued_selected_body, _insert_selected_reservation, ) @@ -105,7 +88,6 @@ def _http_request(settings) -> Request: "app": SimpleNamespace( state=SimpleNamespace( settings=settings, - pre_submission_checker_catalogue=(build_pre_submission_checker_catalogue()), ) ), } @@ -125,8 +107,8 @@ def _replacement_request(h, data: bytes, *, predecessor_id: UUID, idempotency_ke assignment_id=h.request.assignment_id, predecessor_submission_id=predecessor_id, idempotency_key=idempotency_key, - summary=h.request.structural_input.summary, - contributor_attestation=h.request.structural_input.worker_attestation, + summary=h.creation_request.summary, + contributor_attestation=h.creation_request.contributor_attestation, media_type="application/zip", byte_source=artifact_byte_stream(data), ) @@ -149,16 +131,6 @@ async def _prepare(h, request: SubmissionBundlePreparationRequest): ).prepare(request) -async def _snapshot(h) -> tuple[tuple[str, int], ...]: - async with h.factory() as session: - rows = [] - for table in _PREPARATION_TABLES: - count = await session.scalar(text(f"SELECT pg_catalog.count(*) FROM public.{table}")) - assert count is not None - rows.append((table, int(count))) - return tuple(rows) - - async def _fixture_revision_context(h): """Commit the real routed checker failure before replacement intake.""" async with h.factory() as session, session.begin(): @@ -260,15 +232,18 @@ async def test_real_replacement_compares_consumed_predecessor_and_replays_exactl SubmissionBundleAdmission, str(h.creation_request.admission_id) ) admission = await session.get(SubmissionBundleAdmission, str(admission_id)) - evidence = await session.get(PreSubmitEvidenceSet, admission.pre_submit_evidence_set_id) + selected = await session.get(SelectedPreSubmitRun, admission.selected_pre_submit_run_id) assert predecessor_admission.status == "consumed" assert admission.status == "ready" assert admission.predecessor_submission_id == str(predecessor_id) assert admission.predecessor_submission_version == 1 assert admission.checker_remediation_manifest_id == source_id - assert evidence.predecessor_submission_id == str(predecessor_id) - assert evidence.predecessor_submission_version == 1 - assert evidence.checker_remediation_manifest_id == source_id + assert admission.pre_submit_evidence_set_id is None + assert selected is not None and selected.eligible + lineage = selected.request_json["admission_lineage"] + assert lineage["predecessor_submission_id"] == str(predecessor_id) + assert lineage["predecessor_submission_version"] == 1 + assert lineage["checker_remediation_manifest_id"] == str(source_id) assert admission.archive_sha256 != predecessor_admission.archive_sha256 assert ( admission.semantic_manifest_sha256 != predecessor_admission.semantic_manifest_sha256 @@ -379,19 +354,19 @@ async def test_unchanged_replacement_archive_or_manifest_denied( provider_writes.clear() predecessor_id = h.created.submission_id for data, expected in ( - (h.data, SubmissionChangeFailureCode.ARCHIVE_UNCHANGED), + (h.data, "submission_archive_unchanged"), ( _same_semantics_different_archive(h.data), - SubmissionChangeFailureCode.MANIFEST_UNCHANGED, + "submission_manifest_unchanged", ), ): request = _replacement_request( h, data, predecessor_id=predecessor_id, idempotency_key=new_record_id(), ) - with pytest.raises(SubmissionChangeRejectedError) as failure: + with pytest.raises(SubmissionBundlePreparationCheckFailed) as failure: await _prepare(h, request) - assert failure.value.code is expected - assert await _snapshot(h) == baseline + await _assert_default_failure(h, request, failure.value, baseline, expected) + baseline = await _snapshot(h) assert provider_writes == [] admission_id = await _verified_admission( @@ -407,35 +382,58 @@ async def test_unchanged_replacement_archive_or_manifest_denied( async def test_blocked_replacement_precheck_preserves_revision_state( - tmp_path, isolated_database_env, monkeypatch, + tmp_path, + isolated_database_env, + monkeypatch, ): provider_writes = _trace_provider_writes(monkeypatch) async with remediation_source(tmp_path, isolated_database_env) as h: await _fixture_revision_context(h) provider_writes.clear() changed_data = revision_archive(h.data) + baseline = await _snapshot(h) + request = replace( + _replacement_request( + h, + _secret_archive(changed_data), + predecessor_id=h.created.submission_id, + idempotency_key=new_record_id(), + ), + contributor_attestation="", + ) with pytest.raises(SubmissionBundlePreparationCheckFailed) as blocked: - await _prepare(h, replace( - _replacement_request( - h, changed_data, predecessor_id=h.created.submission_id, - idempotency_key=new_record_id(), - ), - contributor_attestation="", - )) - assert blocked.value.facts.eligible is False - assert any(entry.checker_execution_status == "failed" - for entry in blocked.value.facts.entries) + await _prepare(h, request) + await _assert_default_failure( + h, + request, + blocked.value, + baseline, + "high_confidence_secret_detected", + ) + assert tuple( + warning.code for warning in blocked.value.facts.workstream_default.warnings + ) == ("submission_attestation_missing",) assert provider_writes == [] async with h.factory() as session: assert await session.scalar(text("SELECT count(*) FROM public.submissions")) == 1 - assert await session.scalar(text( - "SELECT status FROM public.workstream_tasks WHERE id=:id" - ), {"id": h.request.task_id}) == "needs_revision" + assert ( + await session.scalar( + text("SELECT status FROM public.workstream_tasks WHERE id=:id"), + {"id": h.request.task_id}, + ) + == "needs_revision" + ) admission_id = await _verified_admission( - h.factory, h.store, h.namespace, h.settings, h.actor_context, + h.factory, + h.store, + h.namespace, + h.settings, + h.actor_context, _replacement_request( - h, changed_data, predecessor_id=h.created.submission_id, + h, + changed_data, + predecessor_id=h.created.submission_id, idempotency_key=new_record_id(), ), ) @@ -463,20 +461,22 @@ async def traced_load(self, task_context): await _fixture_revision_context(h) loaded.clear() for data in (h.data, _same_semantics_different_archive(h.data)): - with pytest.raises(SubmissionChangeRejectedError): + with pytest.raises(SubmissionBundlePreparationCheckFailed): await _prepare(h, _replacement_request( h, data, predecessor_id=predecessor_id, idempotency_key=new_record_id(), )) changed_data = revision_archive(h.data) - with pytest.raises(SubmissionBundlePreparationCheckFailed): - await _prepare(h, replace( - _replacement_request( - h, changed_data, predecessor_id=predecessor_id, - idempotency_key=new_record_id(), - ), - contributor_attestation="", - )) + baseline = await _snapshot(h) + secret_request = _replacement_request( + h, _secret_archive(changed_data), predecessor_id=predecessor_id, + idempotency_key=new_record_id(), + ) + with pytest.raises(SubmissionBundlePreparationCheckFailed) as blocked: + await _prepare(h, secret_request) + await _assert_default_failure( + h, secret_request, blocked.value, baseline, "high_confidence_secret_detected", + ) idempotency_key = new_record_id() admission_id = await _verified_admission( h.factory, h.store, h.namespace, h.settings, h.actor_context, @@ -556,207 +556,159 @@ async def test_replacement_dispatch_sql_rejects_cause_and_receipt_substitution( ) is False, field -async def test_sql_evidence_insert_rejects_superseded_checker_cause( - tmp_path, isolated_database_env, +async def test_sql_selected_pre_reservation_rejects_superseded_checker_cause( + tmp_path, + isolated_database_env, ): async with remediation_source(tmp_path, isolated_database_env) as h: source_id = await _fixture_revision_context(h) admission_id = await _verified_admission( - h.factory, h.store, h.namespace, h.settings, h.actor_context, + h.factory, + h.store, + h.namespace, + h.settings, + h.actor_context, _replacement_request( - h, revision_archive(h.data), predecessor_id=h.created.submission_id, + h, + revision_archive(h.data), + predecessor_id=h.created.submission_id, idempotency_key=new_record_id(), ), ) - async with h.factory() as session: - admission = await session.get(SubmissionBundleAdmission, str(admission_id)) - assert admission.status == "ready" - assert admission.checker_remediation_manifest_id == source_id - donor_id = admission.pre_submit_evidence_set_id - donor = (await session.execute(text( - "SELECT attempt_id,attempt_request_digest FROM public.pre_submit_evidence_sets " - "WHERE id=:id" - ), {"id": donor_id})).one() - result_ids = list((await session.execute(text( - "SELECT id FROM public.pre_submit_evidence_results " - "WHERE evidence_set_id=:id ORDER BY result_order" - ), {"id": donor_id})).scalars()) - assert result_ids - + positive = await _queued_selected_body(h, admission_id) + assert positive["request_json"]["admission_lineage"][ + "checker_remediation_manifest_id" + ] == str(source_id) + await _insert_selected_reservation(h, positive) + rejected = await _queued_selected_body(h, admission_id) await reserve(h, await next_request(h)) before = await _snapshot(h) async with h.factory() as session: - current_run_id = await session.scalar(text( - "SELECT current_run_id FROM public.checker_submission_fences " - "WHERE submission_id=:id" - ), {"id": h.created.submission_id}) - routed_run_id = await session.scalar(text( - "SELECT checker_run_id FROM public.task_post_submit_routing_manifests " - "WHERE id=:id" - ), {"id": source_id}) - assert current_run_id != routed_run_id - attempt_id, evidence_id = new_record_id(), new_record_id() - operation = canonical_json_hash({"test_clone_evidence_id": str(evidence_id)}) + current = await session.scalar( + text( + "SELECT current_run_id FROM public.checker_submission_fences WHERE submission_id=:id" + ), + {"id": h.created.submission_id}, + ) + routed = await session.scalar( + text( + "SELECT checker_run_id FROM public.task_post_submit_routing_manifests WHERE id=:id" + ), + {"id": source_id}, + ) + assert current != routed with pytest.raises( - IntegrityError, match="pre-submit remediation evaluation is not current", - ): - async with h.factory() as session, session.begin(): - await session.execute(text(""" - INSERT INTO public.pre_submit_execution_attempts - SELECT (pg_catalog.jsonb_populate_record( - NULL::public.pre_submit_execution_attempts, - pg_catalog.to_jsonb(a) || pg_catalog.jsonb_build_object( - 'id', CAST(:attempt_id AS text), - 'idempotency_key', CAST(:idempotency_key AS text), - 'claim_nonce', CAST(:claim_nonce AS text), - 'status', 'reserved', 'evidence_set_id', NULL, - 'created_at', transaction_timestamp() - ))).* - FROM public.pre_submit_execution_attempts a WHERE a.id=:donor_attempt - """), { - "attempt_id": str(attempt_id), "idempotency_key": str(new_record_id()), - "claim_nonce": str(new_record_id()), "donor_attempt": donor.attempt_id, - }) - await session.execute(text(""" - INSERT INTO public.pre_submit_evidence_sets - SELECT (pg_catalog.jsonb_populate_record( - NULL::public.pre_submit_evidence_sets, - pg_catalog.to_jsonb(e) || pg_catalog.jsonb_build_object( - 'id', CAST(:evidence_id AS text), - 'operation_identity', CAST(:operation AS text), - 'attempt_id', CAST(:attempt_id AS text), - 'created_at', transaction_timestamp() - ))).* - FROM public.pre_submit_evidence_sets e WHERE e.id=:donor_id - """), { - "evidence_id": str(evidence_id), "operation": operation, - "attempt_id": str(attempt_id), "donor_id": donor_id, - }) - for result_id in result_ids: - await session.execute(text(""" - INSERT INTO public.pre_submit_evidence_results - SELECT (pg_catalog.jsonb_populate_record( - NULL::public.pre_submit_evidence_results, - pg_catalog.to_jsonb(r) || pg_catalog.jsonb_build_object( - 'id', CAST(:new_id AS text), - 'evidence_set_id', CAST(:evidence_id AS text), - 'created_at', transaction_timestamp() - ))).* - FROM public.pre_submit_evidence_results r WHERE r.id=:donor_id - """), { - "new_id": str(new_record_id()), - "evidence_id": str(evidence_id), "donor_id": result_id, - }) - await session.execute(text(""" - UPDATE public.pre_submit_execution_attempts - SET status='completed',evidence_set_id=:evidence_id WHERE id=:attempt_id - """), {"evidence_id": evidence_id, "attempt_id": attempt_id}) + IntegrityError, match="selected PRE reservation routed cause is not current" + ) as denied: + await _insert_selected_reservation(h, rejected) + assert denied.value.orig.sqlstate == "23514" assert await _snapshot(h) == before + async with h.factory() as session: + admission = await session.get(SubmissionBundleAdmission, str(admission_id)) + assert admission.status == "ready" + assert admission.checker_remediation_manifest_id == source_id + assert await session.scalar(text("SELECT count(*) FROM public.submissions")) == 1 + await no_economic_effects(session) -async def test_sql_attempt_reconstruction_rejects_foreign_routed_cause( - tmp_path, isolated_database_env, +async def test_sql_selected_pre_reservation_and_admission_reject_foreign_routed_cause( + tmp_path, + isolated_database_env, ): async with remediation_source(tmp_path / "local", isolated_database_env) as local: async with remediation_source( - tmp_path / "foreign", isolated_database_env, provision_services=False, + tmp_path / "foreign", + isolated_database_env, + provision_services=False, storage_settings=local.settings, ) as foreign: local_source = await _fixture_revision_context(local) foreign_source = await _fixture_revision_context(foreign) admission_id = await _verified_admission( - local.factory, local.store, local.namespace, local.settings, + local.factory, + local.store, + local.namespace, + local.settings, local.actor_context, _replacement_request( - local, revision_archive(local.data), + local, + revision_archive(local.data), predecessor_id=local.created.submission_id, idempotency_key=new_record_id(), ), ) - async with local.factory() as session: - admission = await session.get(SubmissionBundleAdmission, str(admission_id)) - assert admission.status == "ready" - assert admission.checker_remediation_manifest_id == local_source - donor_id = admission.pre_submit_evidence_set_id - donor = (await session.execute(text(""" - SELECT e.attempt_id, a.request_json::jsonb AS request_body - FROM public.pre_submit_evidence_sets e - JOIN public.pre_submit_execution_attempts a ON a.id=e.attempt_id - WHERE e.id=:id - """), {"id": donor_id})).mappings().one() - result_ids = list((await session.execute(text( - "SELECT id FROM public.pre_submit_evidence_results " - "WHERE evidence_set_id=:id ORDER BY result_order" - ), {"id": donor_id})).scalars()) - assert result_ids - request_body = dict(donor["request_body"]) - assert request_body["checker_remediation_manifest_id"] == str(local_source) - request_body["checker_remediation_manifest_id"] = str(foreign_source) - request_digest = canonical_json_hash(request_body) - attempt_id, evidence_id = new_record_id(), new_record_id() - operation = canonical_json_hash({"test_clone_evidence_id": str(evidence_id)}) + positive = await _queued_selected_body(local, admission_id) + assert positive["request_json"]["admission_lineage"][ + "checker_remediation_manifest_id" + ] == str(local_source) + await _insert_selected_reservation(local, positive) + rejected = await _queued_selected_body(local, admission_id, cause=foreign_source) before = await _snapshot(local) - with pytest.raises(IntegrityError, match="pre-submit evidence resource mismatch"): + with pytest.raises( + IntegrityError, match="selected PRE reservation routed cause is foreign" + ) as denied: + await _insert_selected_reservation(local, rejected) + assert denied.value.orig.sqlstate == "23514" + assert await _snapshot(local) == before + with pytest.raises( + DBAPIError, match="selected PRE request custody is immutable" + ) as immutable: async with local.factory.begin() as session: - await session.execute(text(""" - INSERT INTO public.pre_submit_execution_attempts - SELECT (pg_catalog.jsonb_populate_record( - NULL::public.pre_submit_execution_attempts, - pg_catalog.to_jsonb(a) || pg_catalog.jsonb_build_object( - 'id', CAST(:attempt_id AS text), - 'idempotency_key', CAST(:idempotency_key AS text), - 'claim_nonce', CAST(:claim_nonce AS text), - 'request_json', CAST(:request_body AS jsonb), - 'request_digest', CAST(:request_digest AS text), - 'status', 'reserved', 'evidence_set_id', NULL, - 'created_at', transaction_timestamp() - ))).* - FROM public.pre_submit_execution_attempts a WHERE a.id=:donor_attempt - """), { - "attempt_id": str(attempt_id), - "idempotency_key": str(new_record_id()), - "claim_nonce": str(new_record_id()), - "request_body": json.dumps(request_body), - "request_digest": request_digest, - "donor_attempt": donor["attempt_id"], - }) - await session.execute(text(""" - INSERT INTO public.pre_submit_evidence_sets + await session.execute( + text(""" + UPDATE public.selected_pre_submit_runs + SET request_json=CAST(:request AS jsonb), request_digest=:digest WHERE id=:id + """), + { + "id": positive["id"], + "request": json.dumps( + positive["request_json"] + | { + "admission_lineage": rejected["request_json"][ + "admission_lineage" + ] + } + ), + "digest": canonical_json_hash( + positive["request_json"] + | { + "admission_lineage": rejected["request_json"][ + "admission_lineage" + ] + } + ), + }, + ) + assert immutable.value.orig.sqlstate == "55000" + assert await _snapshot(local) == before + with pytest.raises( + IntegrityError, match="submission bundle admission verified lineage mismatch" + ) as mismatch: + async with local.factory.begin() as session: + await session.execute( + text(""" + INSERT INTO public.submission_bundle_admissions SELECT (pg_catalog.jsonb_populate_record( - NULL::public.pre_submit_evidence_sets, - pg_catalog.to_jsonb(e) || pg_catalog.jsonb_build_object( - 'id', CAST(:evidence_id AS text), - 'operation_identity', CAST(:operation AS text), - 'attempt_id', CAST(:attempt_id AS text), - 'attempt_request_digest', CAST(:request_digest AS text), - 'created_at', transaction_timestamp() - ))).* - FROM public.pre_submit_evidence_sets e WHERE e.id=:donor_id - """), { - "evidence_id": str(evidence_id), "operation": operation, - "attempt_id": str(attempt_id), "request_digest": request_digest, - "donor_id": donor_id, - }) - for result_id in result_ids: - await session.execute(text(""" - INSERT INTO public.pre_submit_evidence_results - SELECT (pg_catalog.jsonb_populate_record( - NULL::public.pre_submit_evidence_results, - pg_catalog.to_jsonb(r) || pg_catalog.jsonb_build_object( - 'id', CAST(:new_id AS text), - 'evidence_set_id', CAST(:evidence_id AS text), - 'created_at', transaction_timestamp() - ))).* - FROM public.pre_submit_evidence_results r WHERE r.id=:donor_id - """), { - "new_id": str(new_record_id()), - "evidence_id": str(evidence_id), "donor_id": result_id, - }) - await session.execute(text(""" - UPDATE public.pre_submit_execution_attempts - SET status='completed',evidence_set_id=:evidence_id WHERE id=:attempt_id - """), {"evidence_id": evidence_id, "attempt_id": attempt_id}) + NULL::public.submission_bundle_admissions, to_jsonb(a) + || pg_catalog.jsonb_build_object( + 'id',CAST(:new_id AS uuid), + 'checker_remediation_manifest_id',CAST(:cause AS uuid) + ) + )).* FROM public.submission_bundle_admissions a WHERE a.id=:id + """), + {"new_id": new_record_id(), "cause": foreign_source, "id": admission_id}, + ) + assert mismatch.value.orig.sqlstate == "23514" assert await _snapshot(local) == before + async with local.factory() as session: + admission = await session.get(SubmissionBundleAdmission, str(admission_id)) + assert ( + admission.status == "ready" and admission.selected_pre_submit_run_id is not None + ) + assert admission.pre_submit_evidence_set_id is None + assert admission.checker_remediation_manifest_id == local_source + assert await session.scalar(text("SELECT count(*) FROM public.submissions")) == 2 + await no_economic_effects(session) @pytest.mark.parametrize("advance_fence", [False, True]) @@ -953,26 +905,50 @@ async def forbidden_load(*_args, **_kwargs): )}) is False -@pytest.mark.parametrize("recommendation", ["task_setup_blocked", "allow_review"]) -async def test_setup_fault_or_success_cannot_authorize_replacement_before_bytes( - tmp_path, isolated_database_env, monkeypatch, recommendation, +@pytest.mark.parametrize("outcome", ["infrastructure_failed", "completed"]) +async def test_nonremediation_outcome_cannot_authorize_replacement_before_bytes( + tmp_path, + isolated_database_env, + monkeypatch, + outcome, ): provider_writes = _trace_provider_writes(monkeypatch) - async with completed_evidence(tmp_path, isolated_database_env, recommendation) as h: - assert h.run.routing_recommendation == recommendation + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + with monkeypatch.context() as failure: + if outcome == "infrastructure_failed": + + async def unavailable(*_args, **_kwargs): + raise ArtifactStoreUnavailableError("selected input unavailable") + yield b"" # Preserve ArtifactStore.open's async-iterator port. + + failure.setattr(h.store, "open", unavailable) + result = await live_executor(h).evaluate_post_submission(h.request) + assert result.outcome == outcome + async with h.factory() as session: + run = await session.get(CheckerRun, str(result.attempt_id)) + assert run.status == outcome + assert run.routing_recommendation == ( + "not_evaluated" if outcome == "infrastructure_failed" else "allow_review" + ) before = await _snapshot(h) provider_writes.clear() with pytest.raises( SubmissionBundlePreparationRejected, match="submission_bundle_preparation_context_changed", ): - await _prepare(h, replace( - _replacement_request( - h, revision_archive(h.data), predecessor_id=h.created.submission_id, - idempotency_key=new_record_id(), + await _prepare( + h, + replace( + _replacement_request( + h, + revision_archive(h.data), + predecessor_id=h.created.submission_id, + idempotency_key=new_record_id(), + ), + byte_source=_forbidden_bytes(), ), - byte_source=_forbidden_bytes(), - )) + ) assert await _snapshot(h) == before assert provider_writes == [] diff --git a/backend/tests/test_submission_preparation_adapter.py b/backend/tests/test_submission_preparation_adapter.py index d79202285..ad608ab78 100644 --- a/backend/tests/test_submission_preparation_adapter.py +++ b/backend/tests/test_submission_preparation_adapter.py @@ -50,7 +50,9 @@ async def test_final_preparation_rejects_cross_request_facts() -> None: assignment_id=new_record_id(), predecessor_submission_id=None, predecessor_submission_version=None, - pre_submit_evidence_set_id=new_record_id(), + pre_submit_evidence_set_id=None, + selected_pre_submit_run_id=new_record_id(), + selected_terminal_digest=_sha("9"), prepared_generation_id=new_record_id(), guide_id=new_record_id(), guide_version="v1", @@ -63,6 +65,8 @@ async def test_final_preparation_rejects_cross_request_facts() -> None: effective_plan_sha256=_sha("4"), semantic_manifest_id=new_record_id(), semantic_manifest_sha256=_sha("5"), + semantic_manifest_body={"schema_version": "submission_manifest.v1", "entries": []}, + packet_sha256=_sha("8"), archive_sha256=_sha("6"), archive_byte_count=42, media_type="application/zip", diff --git a/backend/tests/test_submission_preparation_authorization.py b/backend/tests/test_submission_preparation_authorization.py index 02eefe29d..3dd77f83c 100644 --- a/backend/tests/test_submission_preparation_authorization.py +++ b/backend/tests/test_submission_preparation_authorization.py @@ -3,6 +3,7 @@ from types import SimpleNamespace from uuid import UUID from app.core.identifiers import new_record_id +from app.core.hashing import canonical_json_hash import pytest @@ -47,7 +48,11 @@ def _final_values(request_values): "effective_policy_sha256": "sha256:" + "2" * 64, "pre_submit_policy_sha256": "sha256:" + "3" * 64, "effective_plan_sha256": "sha256:" + "4" * 64, - "semantic_manifest_sha256": "sha256:" + "5" * 64, + "semantic_manifest_body": {"schema_version": "submission_manifest.v1", "entries": []}, + "semantic_manifest_sha256": canonical_json_hash( + {"schema_version": "submission_manifest.v1", "entries": []} + ), + "packet_sha256": "sha256:" + "8" * 64, "archive_sha256": "sha256:" + "6" * 64, "archive_byte_count": 42, "media_type": "application/zip", diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 1813c52aa..e4c8b0aa5 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -173,7 +173,7 @@ async def test_task_repository_locks_initial_and_revision_submission_context() - locked_pre_submit_checker_policy_id=str(references.pre_submit_policy_id), locked_pre_submit_checker_bundle_hash=references.pre_submit_policy_bundle_hash, ) - from tests.checkers.post_submit.support import request as evaluation_request + from tests.checkers.execution.support import selected_request from app.modules.tasks.api.transition_audit import TaskPolicyLineage base_context = submission_context_facts( @@ -182,10 +182,10 @@ async def test_task_repository_locks_initial_and_revision_submission_context() - submitter_contribution_policy_version_id=references.locked_contribution_policy_version_id, locked_project_context=references, ) - post_policy = evaluation_request(project_id=references.project_id).policy + post_policy = selected_request(project_id=references.project_id).policy lineage = TaskPolicyLineage(**{ **base_context.locked_policy.model_dump(), - "locked_post_submit_checker_policy_hash": post_policy.policy_hash, + "locked_post_submit_checker_policy_hash": post_policy.policy_sha256, }) for field, value in lineage.model_dump().items(): setattr(task, field, str(value) if field.endswith("_id") and field != "locked_contribution_policy_version_id" else value) @@ -255,24 +255,6 @@ async def test_task_repository_locks_initial_and_revision_submission_context() - for statement in assignment_statements ) - session.scalar = AsyncMock(return_value=assignment) - repository.get_latest_submission_for_task = AsyncMock(return_value=None) - task.status = "in_progress" - from app.modules.projects.post_submit_policy import build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec - substituted = compile_project_post_submit_checker_spec( - project_id=str(references.project_id), guide_version=references.guide_version, - spec=build_project_post_submit_checker_spec(project_id=str(references.project_id), - guide_version=references.guide_version, required_checkers=[]), - ) - assert substituted.policy_hash != lineage.locked_post_submit_checker_policy_hash - task.locked_post_submit_checker_policy_body = substituted.model_dump(mode="json") - with pytest.raises(TaskSubmissionContextUnavailable, match="task_submission_context_invalid"): - await repository.select_submission_context(TaskSubmissionContextRequest( - task_id=task_id, assignment_id=assignment_id, contributor_id=contributor_id, - predecessor_submission_id=None, - )) - - @pytest.mark.asyncio async def test_task_repository_rejects_stale_submission_predecessor() -> None: """Reject a predecessor selector that is no longer the latest Submission.""" @@ -603,7 +585,7 @@ async def create_policy_bundle_for_guide( guide_id: str, artifact_proposal=None, *, - post_submit_required_checkers: list[str] | None = None, + post_submit_registry_spec=None, ) -> dict: for kind, body in ( ( @@ -649,7 +631,7 @@ async def create_policy_bundle_for_guide( client, project_id, guide_id, artifact_proposal=artifact_proposal or task_artifact_proposal(), request_headers=auth_headers(), - post_submit_required_checkers=post_submit_required_checkers, + post_submit_registry_spec=post_submit_registry_spec, ) @@ -703,7 +685,9 @@ def complete_submission_payload(package_hash: str = "sha256:package-v1") -> dict } -async def create_active_project(client: AsyncClient, *, slug: str = "task-queue-project") -> dict: +async def create_active_project( + client: AsyncClient, *, slug: str = "task-queue-project", post_submit_registry_spec=None, +) -> dict: project_response = await client.post( "/api/v1/projects", headers=auth_headers() | {"Idempotency-Key": str(new_record_id())}, @@ -723,7 +707,9 @@ async def create_active_project(client: AsyncClient, *, slug: str = "task-queue- ) assert guide_response.status_code == 201, guide_response.text guide = guide_response.json() - await create_policy_bundle_for_guide(client, project["id"], guide["id"]) + await create_policy_bundle_for_guide( + client, project["id"], guide["id"], post_submit_registry_spec=post_submit_registry_spec, + ) await seed_active_guide_for_downstream_test( db_session.get_session_factory(), @@ -1542,20 +1528,7 @@ async def test_release_rejects_crossed_post_submit_policy_sidecar( assert persisted_task is not None assert persisted_task.status == "screening" assert persisted_task.locked_post_submit_checker_policy_body == locked_body - assert "check_acceptance_criteria_present" not in [ - entry["checker_id"] - for entry in locked_body["entries"] - if entry["classification"] == "project_required" - ] - assert "check_acceptance_criteria_present" not in [ - entry["checker_id"] for entry in locked_body["entries"] - ] - assert "check_required_files" in [ - entry["checker_id"] - for entry in locked_body["entries"] - if entry["classification"] == "platform_default" - ] - assert "check_required_files" in [entry["checker_id"] for entry in locked_body["entries"]] + async def test_worker_task_response_redacts_locked_policy_hashes( @@ -2448,7 +2421,8 @@ async def test_retained_submission_versions_are_readable_without_exposing_packet task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - project = await create_active_project(task_client) + from tests.checkers.external.support import post_registry_spec + project = await create_active_project(task_client, post_submit_registry_spec=post_registry_spec()) started_task = await create_started_task(task_client, project["id"], monkeypatch) v1_payload = complete_submission_payload() v1_id = await seed_retained_submission( @@ -2557,7 +2531,8 @@ async def test_retained_version_read_does_not_rewrite_prior_finalized_packet( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - project = await create_active_project(task_client) + from tests.checkers.external.support import post_registry_spec + project = await create_active_project(task_client, post_submit_registry_spec=post_registry_spec()) started_task = await create_started_task(task_client, project["id"], monkeypatch) v1_payload = complete_submission_payload() v1_id = await seed_retained_submission( @@ -2904,63 +2879,3 @@ async def test_task_metadata_round_trips(task_client: AsyncClient) -> None: assert task is not None assert task.skill_tags == ["stem", "proofs"] assert task.source_payload_hash == "hash-123" - - -@pytest.mark.parametrize("transition", ("screen", "release")) -@pytest.mark.parametrize("phase", ("pre", "post_missing", "post_substituted")) -async def test_catalogue_rollout_blocks_task_transition_without_writes( - task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, transition: str, phase: str, -) -> None: - from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue - from app.modules.checkers.runner import default_checker_registry - - project = await create_active_project(task_client) - task = await create_draft_task(task_client, project["id"]) - if transition == "release": - screened = await task_client.post( - f"/api/v1/tasks/{task['id']}/screen", headers=auth_headers(), - json={"reason": "initial screening"}, - ) - assert screened.status_code == 200, screened.text - task_query = select(WorkstreamTask.__table__).where(WorkstreamTask.id == task["id"]) - audit_query = select(AuditEvent.id).where(AuditEvent.entity_id == task["id"]).order_by(AuditEvent.id) - async with db_session.get_session_factory()() as session: - before = dict((await session.execute(task_query)).mappings().one()) - audits = list(await session.scalars(audit_query)) - if transition == "screen": - assert before["status"] == "draft" - assert all(value is None for key, value in before.items() if key.startswith("locked_")) - else: - assert before["status"] == "screening" - if phase.startswith("post_"): - metadata = current_post_submit_catalogue() - registry = default_checker_registry() - missing = "check_acceptance_criteria_present" - if phase == "post_missing": - registry._checkers.pop(missing) - else: - registry._checkers[missing] = registry.resolve("check_evidence_present") - monkeypatch.setattr("app.modules.checkers.runner.default_checker_registry", lambda: registry) - assert current_post_submit_catalogue() == metadata - else: - from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue - disabled = build_pre_submission_checker_catalogue().entries[0].stable_id - monkeypatch.setattr(get_settings(), "artifact_pre_submission_checker_disabled_ids", disabled) - if transition == "release": - historical = await task_client.get( - f"/api/v1/projects/{project['id']}/tasks/{task['id']}/locked-context", headers=auth_headers(), - ) - assert historical.status_code == 200, historical.text - response = await task_client.post( - f"/api/v1/tasks/{task['id']}/{transition}", headers=auth_headers(), - json={"reason": "must remain unchanged"}, - ) - assert response.status_code == 422, response.text - expected = "installed checkers cannot execute the locked policies" - assert expected in response.text - async with db_session.get_session_factory()() as session: - assert dict((await session.execute(task_query)).mappings().one()) == before - assert list(await session.scalars(audit_query)) == audits - assert list(await session.scalars(select(TaskAssignment.id).where( - TaskAssignment.task_id == task["id"], - ))) == [] diff --git a/cli/internal/api/guide_post_policy.go b/cli/internal/api/guide_post_policy.go index e8e7d4083..ff684936f 100644 --- a/cli/internal/api/guide_post_policy.go +++ b/cli/internal/api/guide_post_policy.go @@ -3,18 +3,21 @@ package api import ( "context" "encoding/json" + jsonv2 "encoding/json/v2" "errors" "math/big" "net/http" "net/url" "regexp" "slices" + "strings" + "unicode/utf8" ) // GuidePostPolicy is an observation of one retained policy, not readiness or authority. type GuidePostPolicy struct { Target PostPolicyTarget `json:"target"` - Policy CompiledPostPolicy `json:"policy"` + Policy ExternalPostPolicy `json:"policy"` Proposal GuideProposal `json:"proposal"` ActivationContext GuideActivationContext `json:"activation_context"` LifecycleStatus string `json:"lifecycle_status"` @@ -33,25 +36,49 @@ type PostPolicyTarget struct { PredecessorPolicyID *string `json:"predecessor_policy_id"` } -type CompiledPostPolicy struct { - SchemaVersion string `json:"schema_version"` - CompilerVersion string `json:"compiler_version"` - ProjectID string `json:"project_id"` - GuideVersion string `json:"guide_version"` - CatalogueID string `json:"catalogue_id"` - CatalogueSourceVersion string `json:"catalogue_source_version"` - CatalogueSchemaVersion string `json:"catalogue_schema_version"` - CatalogueManifestHash string `json:"catalogue_manifest_sha256"` - Entries []PostPolicyEntry `json:"entries"` - BlockingSeverities []string `json:"blocking_severities"` +type ExternalPostPolicy struct { + SchemaVersion string `json:"schema_version"` + Entries []ExternalCheckerBinding `json:"entries"` + PolicyHash string `json:"policy_sha256"` } -type PostPolicyEntry struct { - CheckerID string `json:"checker_id"` - DefinitionVersion string `json:"definition_version"` - ImplementationVersion string `json:"implementation_version"` - Classification string `json:"classification"` - Configuration json.RawMessage `json:"configuration"` +type ExternalCheckerBinding struct { + RequirementID string `json:"requirement_id"` + Stage string `json:"stage"` + Registry ExternalCheckerRegistryEntry `json:"registry"` + Configuration json.RawMessage `json:"configuration"` + ConfigurationHash string `json:"configuration_sha256"` +} + +type ExternalCheckerRegistryEntry struct { + CapabilityID string `json:"capability_id"` + CapabilityVersion string `json:"capability_version"` + Phase string `json:"phase"` + ImageDigest string `json:"image_digest"` + ConfigurationSchema ExternalCheckerSchema `json:"configuration_schema"` + InputSchema ExternalCheckerSchema `json:"input_schema"` + OutputSchema ExternalCheckerSchema `json:"output_schema"` + Resources ExternalCheckerResourceLimits `json:"resources"` + RegistryEntryID string `json:"registry_entry_id"` + EntryDigest string `json:"entry_digest"` + RegistrationOperationID string `json:"registration_operation_id"` + RegisteredByActorProfileID string `json:"registered_by_actor_profile_id"` + AuthorizationDecisionEventID string `json:"authorization_decision_event_id"` + CreatedAt string `json:"created_at"` +} + +type ExternalCheckerSchema struct { + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + Document json.RawMessage `json:"document"` + SchemaHash string `json:"schema_sha256"` +} + +type ExternalCheckerResourceLimits struct { + CPUMillis *big.Int `json:"cpu_millis"` + MemoryBytes *big.Int `json:"memory_bytes"` + DeadlineMillis *big.Int `json:"deadline_ms"` + MaximumOutputBytes *big.Int `json:"maximum_output_bytes"` } type GuideActivationContext struct { @@ -106,7 +133,7 @@ func (c *Client) GuidePostPolicy(ctx context.Context, project, guide, compilatio } proposal, err := decodeGuideProposal(fields["proposal"], project, guide, compilation) if err != nil || validatePostTarget(fields["target"], &value.Target, proposal.Value, policy) != nil || - validateCompiledPostPolicy(fields["policy"], &value.Policy, value.Target.Proposal) != nil || + validateExternalPostPolicy(fields["policy"], &value.Policy, value.Target.PolicyHash) != nil || validateActivationContext(fields["activation_context"], &value.ActivationContext) != nil { return result, &Failure{Code: "invalid_api_response"} } @@ -184,45 +211,101 @@ func sameNullableText(a, b *string) bool { } var postPolicyIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,99}$`) +var postPolicySchemaVersion = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{0,49}$`) -func validateCompiledPostPolicy(raw json.RawMessage, value *CompiledPostPolicy, target ProposalTarget) error { - // Public schema defaults are wire values, not a local compiler implementation. - *value = CompiledPostPolicy{SchemaVersion: "post_submit_checker_policy", CompilerVersion: "workstream-post-submit-compiler", - CatalogueID: "workstream.post_submission_checkers", CatalogueSourceVersion: "v0.1", CatalogueSchemaVersion: "post_submission_checker_capability_projection"} - fields, err := contextObject(raw, value, []string{"project_id", "guide_version", "catalogue_manifest_sha256", "entries", "blocking_severities"}, []string{"blocking_severities"}) - if err != nil || value.SchemaVersion != "post_submit_checker_policy" || value.CompilerVersion != "workstream-post-submit-compiler" || - value.CatalogueID != "workstream.post_submission_checkers" || value.CatalogueSourceVersion != "v0.1" || value.CatalogueSchemaVersion != "post_submission_checker_capability_projection" || - !sameUUID(value.ProjectID, target.ProjectID) || value.GuideVersion != target.GuideVersion || - value.CatalogueID != target.PostCatalogueID || value.CatalogueSourceVersion != target.PostCatalogueVersion || - value.CatalogueSchemaVersion != target.PostCatalogueSchemaVersion || value.CatalogueManifestHash != target.PostCatalogueManifestHash || - len(value.Entries) < 1 || len(value.Entries) > 9 || len(value.BlockingSeverities) < 2 || len(value.BlockingSeverities) > 5 { - return errors.New("invalid compiled post-policy") +func validateExternalPostPolicy(raw json.RawMessage, value *ExternalPostPolicy, targetHash string) error { + fields, err := contextObject(raw, value, []string{"schema_version", "entries", "policy_sha256"}, nil) + if err != nil || value.SchemaVersion != "external_post_submit_policy.v1" || + !contextPolicyDigest.MatchString(value.PolicyHash) || value.PolicyHash != targetHash { + return errors.New("invalid external post-policy") } var entries []json.RawMessage - if json.Unmarshal(fields["entries"], &entries) != nil { - return errors.New("invalid entries") + if jsonv2.Unmarshal(fields["entries"], &entries) != nil || entries == nil || len(entries) != len(value.Entries) { + return errors.New("invalid external post-policy entries") } - seen := make(map[string]bool, len(value.Entries)) + var previous [3]string for i := range value.Entries { entry := &value.Entries[i] - _, err := contextObject(entries[i], entry, []string{"checker_id", "definition_version", "implementation_version", "classification", "configuration"}, nil) - var configuration struct{} - if err != nil || seen[entry.CheckerID] || !postPolicyIdentifier.MatchString(entry.CheckerID) || !postPolicyIdentifier.MatchString(entry.ImplementationVersion) || - entry.DefinitionVersion != "v0.1" || !slices.Contains([]string{"platform_default", "project_required", "project_warning"}, entry.Classification) || - decode(entry.Configuration, &configuration, nil, nil) != nil { - return errors.New("invalid policy entry") + if validateExternalCheckerBinding(entries[i], entry) != nil { + return errors.New("invalid external checker binding") } - seen[entry.CheckerID] = true + identity := [3]string{entry.RequirementID, entry.Registry.CapabilityID, entry.Registry.CapabilityVersion} + if i > 0 && compareCheckerBindingIdentity(identity, previous) <= 0 { + return errors.New("external checker bindings are not canonical") + } + previous = identity } - // These are public response invariants, not local catalogue evaluation. - canonical := make([]string, 0, 5) - for _, severity := range []string{"critical", "high", "medium", "low", "info"} { - if slices.Contains(value.BlockingSeverities, severity) { - canonical = append(canonical, severity) + return nil +} + +func compareCheckerBindingIdentity(left, right [3]string) int { + for i := range left { + if comparison := strings.Compare(left[i], right[i]); comparison != 0 { + return comparison } } - if !slices.Contains(canonical, "critical") || !slices.Contains(canonical, "high") || !slices.Equal(canonical, value.BlockingSeverities) { - return errors.New("invalid blocking severities") + return 0 +} + +func validateExternalCheckerBinding(raw json.RawMessage, value *ExternalCheckerBinding) error { + fields, err := contextObject(raw, value, []string{"requirement_id", "stage", "registry", "configuration", "configuration_sha256"}, nil) + if err != nil || utf8.RuneCountInString(value.RequirementID) < 1 || utf8.RuneCountInString(value.RequirementID) > 100 || value.Stage != "post_submit" || + !contextPolicyDigest.MatchString(value.ConfigurationHash) || validateJSONObject(value.Configuration) != nil || + validateExternalCheckerRegistry(fields["registry"], &value.Registry) != nil || value.Registry.Phase != value.Stage { + return errors.New("invalid external checker binding") + } + return nil +} + +func validateExternalCheckerRegistry(raw json.RawMessage, value *ExternalCheckerRegistryEntry) error { + fields, err := contextObject(raw, value, []string{ + "capability_id", "capability_version", "phase", "image_digest", "configuration_schema", "input_schema", "output_schema", "resources", + "registry_entry_id", "entry_digest", "registration_operation_id", "registered_by_actor_profile_id", "authorization_decision_event_id", "created_at", + }, nil) + if err != nil || !postPolicyIdentifier.MatchString(value.CapabilityID) || !postPolicySchemaVersion.MatchString(value.CapabilityVersion) || + value.Phase != "post_submit" || !contextPolicyDigest.MatchString(value.ImageDigest) || !contextPolicyDigest.MatchString(value.EntryDigest) || + !validUUID(value.RegistryEntryID) || !validUUID(value.RegistrationOperationID) || !validUUID(value.RegisteredByActorProfileID) || + !validUUID(value.AuthorizationDecisionEventID) || !validTime(value.CreatedAt) || + validateExternalCheckerSchema(fields["configuration_schema"], &value.ConfigurationSchema, "") != nil || + validateExternalCheckerSchema(fields["input_schema"], &value.InputSchema, "external_checker_post_submit_input") != nil || + validateExternalCheckerSchema(fields["output_schema"], &value.OutputSchema, "external_checker_result") != nil || + validateExternalCheckerResources(fields["resources"], &value.Resources) != nil { + return errors.New("invalid external checker registry entry") + } + identity, valid := uuidIdentity(value.RegistryEntryID) + if !valid || identity[6]>>4 != 7 || identity[8]&0xc0 != 0x80 { + return errors.New("invalid external checker registry identity") + } + return nil +} + +func validateExternalCheckerSchema(raw json.RawMessage, value *ExternalCheckerSchema, requiredID string) error { + _, err := contextObject(raw, value, []string{"schema_id", "schema_version", "document", "schema_sha256"}, nil) + if err != nil || !postPolicyIdentifier.MatchString(value.SchemaID) || !postPolicySchemaVersion.MatchString(value.SchemaVersion) || + (requiredID != "" && value.SchemaID != requiredID) || !contextPolicyDigest.MatchString(value.SchemaHash) || + validateJSONObject(value.Document) != nil { + return errors.New("invalid external checker schema") + } + return nil +} + +func validateExternalCheckerResources(raw json.RawMessage, value *ExternalCheckerResourceLimits) error { + _, err := contextObject(raw, value, []string{"cpu_millis", "memory_bytes", "deadline_ms", "maximum_output_bytes"}, nil) + if err != nil || !boundedInteger(value.CPUMillis, 100, 64_000) || !boundedInteger(value.MemoryBytes, 16*1024*1024, 64*1024*1024*1024) || + !boundedInteger(value.DeadlineMillis, 100, 3_600_000) || !boundedInteger(value.MaximumOutputBytes, 256, 65_536) { + return errors.New("invalid external checker resources") + } + return nil +} + +func boundedInteger(value *big.Int, minimum, maximum int64) bool { + return value != nil && value.IsInt64() && value.Int64() >= minimum && value.Int64() <= maximum +} + +func validateJSONObject(raw json.RawMessage) error { + var value map[string]json.RawMessage + if err := jsonv2.Unmarshal(raw, &value); err != nil || value == nil { + return errors.New("invalid JSON object") } return nil } diff --git a/cli/tests/integration/test_guide_post_policy_http.py b/cli/tests/integration/test_guide_post_policy_http.py index 48e771071..b747d3921 100644 --- a/cli/tests/integration/test_guide_post_policy_http.py +++ b/cli/tests/integration/test_guide_post_policy_http.py @@ -15,6 +15,45 @@ LARGE = 9223372036854775808 +def schema(schema_id): + return { + "schema_id": schema_id, + "schema_version": "v1", + "document": {"type": "object", "additionalProperties": False}, + "schema_sha256": HASH, + } + + +def binding(requirement_id="quality_review", capability_id="acme.quality_review"): + return { + "requirement_id": requirement_id, + "stage": "post_submit", + "registry": { + "capability_id": capability_id, + "capability_version": "v1.2.3", + "phase": "post_submit", + "image_digest": HASH, + "configuration_schema": schema(f"{capability_id}.configuration"), + "input_schema": schema("external_checker_post_submit_input"), + "output_schema": schema("external_checker_result"), + "resources": { + "cpu_millis": 500, + "memory_bytes": 128 * 1024 * 1024, + "deadline_ms": 30_000, + "maximum_output_bytes": 32_768, + }, + "registry_entry_id": POLICY, + "entry_digest": HASH, + "registration_operation_id": OTHER, + "registered_by_actor_profile_id": GUIDE, + "authorization_decision_event_id": SETUP, + "created_at": "2026-10-12T08:30:00Z", + }, + "configuration": {}, + "configuration_sha256": HASH, + } + + def package(): source = proposal() source["target"].update( @@ -40,26 +79,9 @@ def package(): "predecessor_policy_id": None, }, "policy": { - "schema_version": "post_submit_checker_policy", - "compiler_version": "workstream-post-submit-compiler", - "project_id": PROJECT, - "guide_version": source["target"]["guide_version"], - "catalogue_id": source["target"]["post_catalogue_id"], - "catalogue_source_version": "v0.1", - "catalogue_schema_version": source["target"][ - "post_catalogue_schema_version" - ], - "catalogue_manifest_sha256": HASH, - "entries": [ - { - "checker_id": "content_required", - "definition_version": "v0.1", - "implementation_version": "content_required", - "classification": "platform_default", - "configuration": {}, - } - ], - "blocking_severities": ["critical", "high"], + "schema_version": "external_post_submit_policy.v1", + "entries": [binding()], + "policy_sha256": HASH, }, "proposal": source, "activation_context": { @@ -119,18 +141,6 @@ def test_post_policy_exact_read_history_defaults_and_terminal_safety(cli): historical["proposal"].update(current=False, post_submit_policy_id=None) historical["target"]["predecessor_policy_id"] = GUIDE bodies.append(historical) - defaults = deepcopy(historical) - for key in ( - "schema_version", - "compiler_version", - "catalogue_id", - "catalogue_source_version", - "catalogue_schema_version", - ): - del defaults["policy"][key] - del defaults["correction"]["status"] - del defaults["target"]["predecessor_policy_id"] - bodies.append(defaults) for value in bodies: response["body"] = json.dumps(value).encode() result = invoke(cli, origin) @@ -166,33 +176,39 @@ def test_post_policy_rejects_valid_uuid_selected_policy_substitution(cli): assert len(requests) == 1 -def test_post_policy_rejects_duplicate_checkers_and_noncanonical_severity_floor(cli): +def test_post_policy_rejects_removed_catalogue_shape_and_noncanonical_bindings(cli): with http_fixture() as (origin, response, requests): value = package() - value["policy"]["blocking_severities"] = [ - "critical", - "high", - "medium", - "low", - "info", - ] + value["policy"]["entries"] = [] response["body"] = json.dumps(value).encode() assert invoke(cli, origin).returncode == 0 duplicate = package() duplicate["policy"]["entries"] *= 2 response["body"] = json.dumps(duplicate).encode() assert_failure(invoke(cli, origin), "invalid_api_response") - for severities in ( - ["low", "info"], - ["high", "critical"], - ["critical", "high", "high"], - ["critical", "high", "unknown"], - ): - value = package() - value["policy"]["blocking_severities"] = severities - response["body"] = json.dumps(value).encode() - assert_failure(invoke(cli, origin), "invalid_api_response") - assert len(requests) == 6 + unordered = package() + unordered["policy"]["entries"] = [ + binding("z_review", "z.review"), + binding("a_review", "a.review"), + ] + response["body"] = json.dumps(unordered).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + removed = package() + removed["policy"] = { + "schema_version": "post_submit_checker_policy", + "compiler_version": "workstream-post-submit-compiler", + "project_id": PROJECT, + "guide_version": "v1", + "catalogue_id": "workstream.post_submission_checkers", + "catalogue_source_version": "v0.1", + "catalogue_schema_version": "post_submission_checker_capability_projection", + "catalogue_manifest_sha256": HASH, + "entries": [], + "blocking_severities": ["critical", "high"], + } + response["body"] = json.dumps(removed).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + assert len(requests) == 4 def test_post_policy_rejects_incomplete_malformed_and_cross_resource_packages(cli): @@ -205,23 +221,21 @@ def test_post_policy_rejects_incomplete_malformed_and_cross_resource_packages(cl (("target", "policy_hash"), "bad"), (("target", "upstream", "acknowledged_warning_hashes"), [None]), (("target", "proposal", "setup_generation"), 0), - (("policy", "project_id"), OTHER), - (("policy", "guide_version"), "wrong"), - (("policy", "catalogue_manifest_sha256"), "sha256:" + "b" * 64), + (("policy", "policy_sha256"), "sha256:" + "b" * 64), (("policy", "entries"), [None]), + (("policy", "entries", 0, "stage"), "pre_submit"), + (("policy", "entries", 0, "configuration"), []), + (("policy", "entries", 0, "configuration_sha256"), "bad"), + (("policy", "entries", 0, "registry", "phase"), "pre_submit"), ( - ("policy", "entries"), - [ - { - "checker_id": "a", - "definition_version": "v0.1", - "implementation_version": "a", - "classification": "platform_default", - "configuration": {"unexpected": True}, - } - ], + ("policy", "entries", 0, "registry", "registry_entry_id"), + "550e8400-e29b-41d4-a716-446655440000", ), - (("policy", "blocking_severities"), [None, "high"]), + (("policy", "entries", 0, "registry", "created_at"), "not-a-time"), + (("policy", "entries", 0, "registry", "input_schema", "schema_id"), "wrong"), + (("policy", "entries", 0, "registry", "output_schema", "document"), []), + (("policy", "entries", 0, "registry", "resources", "cpu_millis"), 99), + (("policy", "entries", 0, "registry", "resources", "deadline_ms"), True), (("policy", "schema_version"), None), (("activation_context", "guide_mutation_generation"), True), ( @@ -262,9 +276,27 @@ def test_post_policy_rejects_incomplete_malformed_and_cross_resource_packages(cl del value[field] response["body"] = json.dumps(value).encode() assert_failure(invoke(cli, origin), "invalid_api_response") + for path, field in ( + (("policy",), "policy_sha256"), + (("policy", "entries", 0), "configuration_sha256"), + (("policy", "entries", 0, "registry"), "entry_digest"), + (("policy", "entries", 0, "registry", "input_schema"), "document"), + (("policy", "entries", 0, "registry", "resources"), "deadline_ms"), + ): + value = package() + node = value + for key in path: + node = node[key] + del node[field] + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") for path in ( ("target",), ("policy",), + ("policy", "entries", 0), + ("policy", "entries", 0, "registry"), + ("policy", "entries", 0, "registry", "input_schema"), + ("policy", "entries", 0, "registry", "resources"), ("activation_context",), ("proposal",), ("proposal", "target"), @@ -282,7 +314,7 @@ def test_post_policy_rejects_incomplete_malformed_and_cross_resource_packages(cl .encode() ) assert_failure(invoke(cli, origin), "invalid_api_response") - assert len(requests) == len(mutations) + len(package()) + 6 + assert len(requests) == len(mutations) + len(package()) + 15 def test_post_policy_local_selectors_and_transport_fail_closed(cli): diff --git a/cli/tests/integration/test_guide_post_policy_public_api.py b/cli/tests/integration/test_guide_post_policy_public_api.py index 73785b09f..0e14b8c68 100644 --- a/cli/tests/integration/test_guide_post_policy_public_api.py +++ b/cli/tests/integration/test_guide_post_policy_public_api.py @@ -31,6 +31,7 @@ ) from tests.projects.guide_activation.source_fixtures import source_case # noqa: E402 from tests.projects.guide_compilation.proposals.pg_support import seed_review_actor # noqa: E402 +from tests.checkers.external.support import post_registry_spec # noqa: E402 @pytest.mark.asyncio @@ -55,7 +56,10 @@ def token(actor): ) # Canonical retained source/compilation prerequisites, not a provider-inference claim. - async with source_case(clean_postgres_database) as ( + async with source_case( + clean_postgres_database, + post_submit_registry_spec=post_registry_spec(), + ) as ( _, factory, command, @@ -195,6 +199,32 @@ async def unchanged(expected_operations): assert response.status_code == 200, response.text compiled = success(read(policy_id)) assert compiled == response.json() + assert compiled["policy"]["schema_version"] == ( + "external_post_submit_policy.v1" + ) + assert ( + compiled["policy"]["policy_sha256"] + == (compiled["target"]["policy_hash"]) + ) + assert len(compiled["policy"]["entries"]) == 1 + selected = compiled["policy"]["entries"][0] + assert ( + selected["stage"] == selected["registry"]["phase"] == "post_submit" + ) + assert selected["registry"]["input_schema"]["schema_id"] == ( + "external_checker_post_submit_input" + ) + assert selected["registry"]["output_schema"]["schema_id"] == ( + "external_checker_result" + ) + assert ( + not { + "catalogue_id", + "catalogue_source_version", + "blocking_severities", + } + & compiled["policy"].keys() + ) assert ( compiled["lifecycle_status"] == "compiled" and compiled["current"] is True diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index 1a827c05d..1eea4e904 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -45,72 +45,16 @@ It is either `completed`, with closed findings and a passed/failed verdict, or `infrastructure_failed`, with one of the existing capacity, deadline, material, implementation or invalid-output codes. Missing images, crashes and malformed or oversized output cannot become a silent pass. This contract and the -immutable registry are available to later owner operations; no external -launcher, routing path or execution factory is installed yet. - -The following catalogue contracts remain the currently executing behavior -until the clean cutover replaces them. They are not alternate external wire -formats. - -The following illustrative provenance envelope uses a pre-submission catalogue -`v1` definition; it is not a universal serialized result schema. The -[current post-submit contract](#current-post-submit-contract) uses -`PostSubmitMemberResult` with schema `post_submit_structural_result` and its -separate bounded fields. - -Illustrative pre-submission provenance envelope: - -```json -{ - "definition": { - "dispatch_authority": "pre_submission_catalogue", - "definition_id": "policy.submission_packet.validate", - "definition_version": "v1", - "public_name": "check_submission_packet", - "source": "locked_project_policy" - }, - "policy_trace": { - "effective_plan_hash": "sha256:<64 lowercase hex>", - "rule_instance_id": "sha256:<64 lowercase hex>", - "locked_policy_hash": "sha256:<64 lowercase hex>" - }, - "status": "passed", - "severity": "info", - "code": "submission_packet_complete", - "message": "Submission packet is complete.", - "suggested_fix": null, - "evidence": [], - "metadata": {} -} -``` - -`definition` and `policy_trace` are typed provenance, not arbitrary metadata. -The discriminating `dispatch_authority` gives `definition_id/version` exact -meaning: for `pre_submission_catalogue` they are the stable catalogue -definition ID/version, while the effective plan separately binds the top-level -catalogue ID/version and manifest hash; for `durable_checker_registry` they are -the registered durable checker ID/version. -For Workstream defaults, `source=workstream_default` and policy-only fields may -be null under the closed schema. Serialization preserves this exact nesting. -Persistence uses explicit authority-neutral columns or schema-validated typed -JSON fields for every member; none may be stored only inside open-ended -`metadata`. - -Status: +immutable registry are consumed by selected hidden PRE/POST owners through an +explicit Rust service adapter. Production request/completion handlers are +registered; public contributor intake remains PILOT-12 work. -- passed -- warning -- failed +Both executable catalogues, their runtime factories and their request/result +decoders are removed. Requests use the default-plus-external PRE contract and +external POST contract. Stored predecessor rows retain their original provenance +and business-version digests; they do not supply an alternate execution path. -Severity: - -- info -- low -- medium -- high -- critical - -## External Checker Registry And Current Catalogues +## External Checker Registry And Selected Policies The immutable external registry publishes one capability/version/phase with an exact OCI `sha256` digest, bounded configuration/input/output JSON Schemas and @@ -123,22 +67,16 @@ schema, request and entry digest while denying update, delete and truncate. Registry metadata grants no policy selection, activation, execution or routing authority. Consumers read an exact registry ID and digest; there is no "latest" lookup and no registry-driven Python plugin or callable discovery. -The registry supports both `pre_submit` and `post_submit`, but no external -execution is wired in this foundation. - -Until the replacement proof and clean cutover, the single versioned -`PreSubmissionCheckerCatalogue` remains the pre-submit dispatch authority and -the current process-wide post-submit catalogue remains the post-submit dispatch -authority. They do not execute registry entries in parallel. - -The current hidden catalogue implementation is process-wide and immutable. -Deployment configuration may name disabled stable definition IDs only at startup. Unknown -or duplicate IDs, invalid dependencies/order, unknown capabilities, and unsafe -disabled behavior fail startup validation. The pure effective-plan compiler -binds project, guide version, source snapshot, effective policy, pre-submit -policy, catalogue manifest, availability state, ordered definition/configuration -hashes, and deterministic rule-instance identities. It does not read artifacts -or invoke either pre-submit or durable checkers. +The registry supports both `pre_submit` and `post_submit`. Registration alone +grants no execution; selected owners execute only exact approved image bindings +with fresh phase authority and ART-verified material. + +The trusted compiler binds approved requirement identities to exact immutable +registry entries, image/configuration/schema digests and ordered PRE/POST +policies. PRE includes the single versioned Workstream default. No executable +pre-submit or post-submit catalogue remains, and no registry-driven Python +implementation discovery is allowed. Retained catalogue identities remain +immutable provenance for earlier records; they do not select new execution. External registry fields include: @@ -152,38 +90,29 @@ External registry fields include: decision receipt. Capability names must not drift between project guides, approved policy -bindings, registry entries and checker results. Later policy work must bind the +bindings, registry entries and checker results. Approved policies bind the exact digest, configuration and schema identities; registration alone changes -no locked task. - -## Current post-submit contract - -CHECKERS owns one immutable initial-v0.1 catalogue, one implementation per -checker ID, and closed evaluation-request/result contracts. PROJECTS uses that -same catalogue to compile one canonical policy body. Active PROJECTS, TASKS and -CHECKERS consumers validate that body and its exact hash; persisted required, -warning and blocking summaries must agree with it. Unsupported development -bodies reject without translation or an alternate reader. - -The eight mandatory structural defaults and the selectable -`check_acceptance_criteria_present` have conformance fixtures. Criteria presence -does not prove that work satisfies the criteria. Unsupported substantive -automation remains a capability gap; an explicitly approved `human_review` -disposition remains valid without a checker binding. - -The single policy-context check compares actual Task and Submission locks: -guide version, source snapshot, effective artifact policy, pre-submit bundle, -post-submit policy, review policy and revision policy. It has no economic-policy -prerequisite. Compilation and ContributionPolicy task references are not yet -persisted and are not fabricated as checker inputs. Project ownership remains -checked by the owning services. Structural consistency grants no authorization. - -The canonical `policy_hash` binds ordered entries, configuration and exact -catalogue/implementation identities. Domain project policy versions record -changes to project rules; they do not select obsolete software implementations. -ARCH-04C implements hidden phase execution; ARCH-04D2 supplies exact service authority. No public execution route or dispatcher is activated. -POL-04B connects unified guide setup; POL-05B and POL-06B expose separate -pre-submit and post-submit policy approvals. +no locked task or approved guide generation. + +## Post-submit contract + +POST requests carry the task's locked `LockedExternalPostSubmitPolicy` and the +exact immutable Submission/ART identity. One request/result contract drives +reservation, execution, completion and recovery. Every approved digest-pinned +image receives verified read-only material; its normalized findings and observed +isolation become immutable evidence for the existing routing owner. + +Policies bind exact registry entries, image digests, configuration and schema +hashes. TASK and ART validate the locked guide, source, policy, assignment, +predecessor and material context before admission or evaluation. Checker results +supply work evidence and do not grant acceptance authority. Passing required +POST checks routes through the existing human-review or shared final-acceptance +branch selected by the locked ReviewPolicy. + +There is no catalogue request/result decoder, generic rule planner, alternate +phase service or old execution path. Removing those implementations does not +rewrite or delete stored records. Unsupported stored envelopes fail closed; +stored historical facts cannot select a checker implementation. ## Durable execution custody @@ -194,9 +123,8 @@ split attempts. Replay returns the original identity; it cannot restore an old request as current. Execution claims a PostgreSQL-timed execution lease and releases all locks and prepared authority before materialization or evaluator work. -The existing in-process post-submit structural-handler registry evaluates exactly -the compiled entries against ART-verified material; it is distinct from the new -external-image metadata registry. Finalization consumes separate action-specific authority, +Selected POST execution runs every approved digest-pinned image against +ART-verified immutable Submission material through the Rust service. Finalization consumes separate action-specific authority, checks the current fence and unexpired lease, and commits complete ordered closed members, terminal result and shared-outbox completion together. Expired-lease recovery retains the attempt; terminal infrastructure failures never restart or @@ -210,24 +138,25 @@ I/O. Execute/finalize receipts are bound to the persisted phase facts by Postgre terminal replay validates both original receipts without appending new evidence. No new Celery task or public execution endpoint exists. ARCH-04E/B8 recheck currentness when consuming a completion event and apply the locked ReviewPolicy; -their hidden handlers remain unregistered. CHECKERS never accepts a contribution +their selected hidden handlers are registered in production composition. CHECKERS never accepts a contribution or mutates TASK state. Migration 0008 refuses retained checker history before changing schema rather than inventing request lineage or deleting data. ## Blocking Policy -The current catalogue behavior remains: - -- critical- and high-severity `failed` results block human review -- medium-severity `failed` result creates reviewer warning -- low-severity `failed` result creates informational note +Selected PRE requires the Workstream default and every required external image +to pass. Selected POST derives `needs_revision` from a failed required image and +`allow_review` from a complete passing set; TASK applies the locked review mode. +The old severity lists belong only to retained structural evidence. -Approved machine policies can declare stricter blocking behavior. `SubmissionArtifactPolicy` and generated project `PreSubmitCheckerPolicy` govern pre-submit artifact rules. `PostSubmitCheckerPolicy` governs durable post-submit checker blocking. +Approved project images enforce their exact required checks. Artifact custody +cannot be weakened by project configuration, and a failed required image cannot +be reclassified into a pass through an administrative grant. Project policy cannot weaken Workstream default submission artifact rules. Workstream defaults are applied before project policy. A project policy that attempts to require a forbidden artifact, remove hash requirements, allow credential-bearing storage references, or downgrade blocking defaults is a project setup defect. -The clean-cutover target has one versioned Workstream default checker with +The selected hidden flow has one versioned Workstream default checker with exactly four blocking behaviors: 1. safely open exactly one ZIP within platform size limits, rejecting @@ -240,10 +169,24 @@ exactly four blocking behaviors: Missing summary or attestation produces warnings only. Required-file, project size and forbidden-file rules belong to digest-pinned external project images. -The default checker, ART custody and project images must replace every required -outcome before either current catalogue is removed. Human reviewers continue +The default checker, ART custody and approved project images replace the removed +executable catalogues. Human reviewers continue to own final quality judgment. +The selected `workstream.default` v1 component implements this closed result +boundary. It consumes detached ART/TASK custody facts and reads verified bytes +only through the existing CHECKERS-owned callback-scoped material capability; +it receives no path or storage authority and introduces no CHECKERS-to-ART +dependency. It reports four ordered blocking slots and separate warning-only +summary/attestation absence; matched secret bytes and contributor text never +enter its result. Malformed or +unsafe contributor ZIPs are bounded work findings, while storage integrity, +inspection timeout, stale predecessor and mixed-custody failures remain +recoverable infrastructure outcomes. The unchanged-work check compares the +canonical predecessor only when its locked task-rules digest is still current. +The selected PRE owner calls this component before required project images; +both executable catalogues are removed. + The clean-cutover intake order is fixed. ART receives the ZIP, computes its SHA-256, verifies the stored bytes, safely opens it within platform limits and builds the manifest before any external image can read material. The one @@ -261,11 +204,18 @@ External image execution never occurs inside that database transaction. A valid locked policy with no project pre-submit bindings is distinct from an unavailable required image and may proceed after the default checker passes. +Guide activation refuses an approved compilation with unresolved required +checker capabilities. After current manager authority and policy custody checks, +the canonical HTTP `error.details.missing_capabilities` lists only each +requirement ID, pre-submit or post-submit stage, and title. It contains no +rationale, evidence paths or provider coordinates. Registration alone cannot +clear a gap: a corrected compilation and approved binding are required. + The target launcher and checker integration use the agreed Rust service and SDK. Registered OCI images are not thereby required to be written in Rust; their execution identity is the approved digest, schemas and resource limits. The hidden runtime mechanism now supplies the strict Rust SDK and long-lived -service plus an unselected typed Python Unix-socket adapter. ART can issue one +service plus an explicitly composed typed Python Unix-socket adapter. ART can issue one opaque request-bound grant only inside the existing pre-admission prepared-ZIP callback; the service independently verifies its sealed file/directory manifest and exact private-root location before a read-only mount. The service accepts @@ -274,103 +224,33 @@ and config image ID all match, and owns no-network, non-root, capability-free, read-only sandbox launch, bounded output/deadline handling and cleanup. Hosted readiness requires `runsc`; local `runc` is reported as `docker-dev`. -This hidden mechanism is not selected by either current catalogue and stores no -run state. Policy binding, caller-owned phase authority/currentness, durable -attempt and isolation receipts, the one default checker, project-image routing, -F-020 and public intake remain required before cutover. -The replacement must remove both current pre- and post-submit catalogues after -end-to-end replacement proof. It must not retain a legacy/new parallel path, -weaken caller-owned atomicity or bypass phase identity and currentness checks. - -## Current Required Core Checkers Pending Removal - -The names below describe the current catalogue pending replacement; they do -not expand the four future Workstream defaults. - -### check_policy_context_present - -Ensures the task has locked guide, checker, review, and revision policy context. -Award eligibility is locked on TaskAssignment for the attempt, stamped on the -immutable Submission, and copied from that Submission to ReviewLease. It is not -checker-policy context. - -### check_submission_packet - -Ensures submission has summary, output reference, and package/evidence where required. - -### check_evidence_present - -Ensures accepted work can be audited. - -### check_evidence_integrity - -Ensures the submission packet records content hashes for uploaded artifacts and that checker runs use those exact hashes. - -### check_acceptance_criteria_present - -Ensures a task has rubric or acceptance criteria. - -### check_required_files - -Validates required submission artifacts from the locked project pre-submit -checker policy. - -### check_forbidden_files - -Blocks known forbidden artifacts, secrets, private keys, copied internal data, or artifacts forbidden by the locked project pre-submit checker policy. - -Default forbidden patterns include: - -- private keys -- API tokens -- `.env` -- copied confidential client/source files -- generated low-quality helper artifacts banned by project submission artifact policy -- files not allowed in the submission packet - -### check_confidentiality_attestation - -Ensures the contributor explicitly attests that the submission does not contain prohibited client data, private source material, credentials, or copied platform artifacts. - -### check_low_quality_generated_artifacts - -Flags repeated low-quality generated patterns banned by project submission artifact policy, such as generic helper files, hidden-test leakage patterns, fabricated model files, placeholder evidence, or boilerplate reports that do not prove task-specific work. - -This mandatory default emits a medium-severity warning for placeholder signals. -It cannot be removed or reclassified as a project-selected entry. Project -blocking severities govern whether that warning requires contributor revision; -only `check_acceptance_criteria_present` is selectable. - -Revision closure, task lifecycle movement, task readiness, and pre-review routing are enforced as lifecycle guards in v0.1. They must not be configured as checker policy names until a registered checker exists for that contract. - -The pre-review gate is a checker execution phase. The persisted task status during this phase is `evaluation_pending`. +The Rust launcher owns sandbox execution; durable CHECKERS owners retain +approved policy binding, phase authority/currentness, attempts, results and +isolation receipts. F-020 requires corrected approved generation after missing +capability registration; registration alone never clears the old warning. +Production completion routes true-policy success to review_pending and eligible +locked false-policy success through shared acceptance. Public false-guide +activation remains PILOT-08 work; public intake remains PILOT-12 work. Local +Rust/MinIO proof uses runc/docker-dev and makes no deployed or gVisor claim. ## Gate Mapping -Project activation gate: - -- `check_policy_context_present` -- project-specific guide completeness checks - -Task screening gate: - -- `check_policy_context_present` -- `check_acceptance_criteria_present` -- task lifecycle transition guards - -Submission quality gate: - -- `check_submission_packet` -- `check_evidence_present` -- `check_evidence_integrity` -- `check_required_files` -- `check_forbidden_files` -- `check_confidentiality_attestation` -- `check_low_quality_generated_artifacts` - -Pre-review gate phase: - -- project-configured registered checkers run against the locked submission and policy context +- Project activation validates approved source/policy identities and exact + registry digests; missing required capability coverage blocks activation. +- TASK screening and release retain their canonical locked-context and lifecycle + guards; registry publication alone grants no Task transition authority. +- Selected PRE runs the four Workstream defaults and all required project images + before caller-owned immutable Submission creation. +- Valid empty image bindings require no external-service configuration. The + fixed Unix provider is resolved only when an image executes; missing required + runtime configuration yields recoverable `implementation_unavailable` evidence. +- Selected POST consumes committed Submission custody and produces durable + evidence. TASK's authorized completion operation alone changes lifecycle state + or invokes shared acceptance. + +Revision closure and task lifecycle movement remain owner guards, not generated +checker names. Public false-policy guide activation is still blocked pending +PILOT-08, and public contributor intake remains PILOT-12 work. ## Submission Artifact Policy And Pre-Submit Generation @@ -405,9 +285,9 @@ policy. Workstream default submission artifact rules require: -- summary +- summary metadata, with absence warning-only - one outer ZIP whose exact identity and semantic manifest are generated by ART -- contributor attestation +- contributor attestation metadata, with absence warning-only - safe relative artifact paths - production artifact hashes shaped as `sha256:<64 lowercase hex>` - pre-cutover only: validated caller-supplied storage references and manifest; @@ -426,15 +306,16 @@ Workstream default submission artifact rules require: advisory or locked project-specific unless an exact generic custody risk is proven -The hidden pre-submit executor consumes one exact effective plan under ART -scratch custody. It validates the commitment, inspection, semantic manifest and -change-gate facts and returns bounded entry results. ART owns the immutable -pre-submit evidence; these results are separate from post-submit CheckerRuns. +The selected hidden PRE owner consumes one exact approved policy under ART +scratch custody. It validates commitment, inspection, semantic manifest and +change-gate facts and returns bounded default/external results. CHECKERS retains +immutable selected run/evidence custody; ART owns verified bytes and admission. +PRE results remain separate from post-submit CheckerRuns. -POL-07B composes exactly two internal commands, `evaluate_pre_submission` and +The earlier POL-07B phase facade composes two internal commands, `evaluate_pre_submission` and `evaluate_post_submission`. Hidden preparation calls the pre command once after reservation commits, including completed replay. The composition passes the -same opaque reservation to the same ART evidence owner; it clears the consumed +same opaque reservation through canonical selected custody; it clears the consumed prepared authorization and opens no transaction before ART's existing executor obtains fresh authority. Replay returns ART's canonical result unchanged. @@ -452,7 +333,7 @@ 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, execute and finalize authority; ARCH-04E/B8 supply the hidden routing operation and completion handler, and 04F2 supplies its remediation branch. Production -registration remains pending. A returned evaluation value is not a stored current +request/completion registration is supplied by PILOT-04. A returned evaluation value is not a stored current result or acceptance. Retained run and submission history now use canonical AUTH and separate fixed contributor/manager projections. The alternate execution service and Celery worker are @@ -504,48 +385,20 @@ Pre-submit results do not create durable `CheckerRun` records, do not move a task to `review_pending`, and do not return review decision values: `accept`, `needs_revision`, or `reject`. -The unified `ProjectGuideCompilationAgent` proposes the artifact-intake -contract alongside sufficiency and both checker-stage bindings in one result. -It does not produce unrestricted checker code. Workstream's trusted -checker compiler builds and validates the project checker specification during -setup, then persists deterministic project-level checker logic using approved -primitives such as: - -- `validate_submission_packet` -- `enforce_storage_scheme` -- `require_manifest_field` -- `verify_hash` -- `require_file` -- `require_minimum_evidence` -- `forbid_artifact` -- `require_attestation` -- `limit_file_size` -- `limit_package_size` -- `limit_archive_size` -- `limit_archive_entries` -- `require_packaging` -- `warn_low_quality_generated_artifact` - -`limit_package_size` evaluates total expanded bytes from ART's verified manifest, -not compressed upload size. `limit_archive_size` separately evaluates the entire -compressed ZIP's verified `ArtifactCommitment.byte_count`, including archive -metadata, not the sum of compressed member sizes. It is an optional strict -positive byte limit; null adds no project limit. `limit_archive_entries` evaluates that manifest's -normalized outer ZIP entry count, including explicit and implied parent -directories. Omitting a ZIP directory record cannot evade this limit. Nested archives count as -files; this rule does not recursively unpack them. Project count is an optional -strict positive integer; null adds no project limit. All three rules preserve ART's -independent platform safety ceilings and block intake through the existing -effective-plan result, without creating acceptance or exposing hidden routes. - -`warn_low_quality_generated_artifact` is warning-only. The trusted compiler -rejects checker specifications that escalate that primitive to blocking. - -Project-specific executable checker code is a future extension path, not the -default. That extension path must require static validation, generated tests, -sandboxed execution, no network, no shell, no secrets, no database access, -covered Project Manager approval of the exact code hash after those checks -pass, and a locked code hash. +The unified `ProjectGuideCompilationAgent` proposes artifact intake, sufficiency +and both checker-stage bindings in one constrained result. It proposes no +unrestricted executable code. The trusted compiler resolves each required +project check to an immutable Operator-registered image, validates bounded typed +configuration against that entry's schema, and locks its exact digest and order. +An unsupported required automated check blocks the guide; a presence check cannot +stand in for a substantive evaluator. The removed primitive catalogue is not an +alternate compiler or runtime path. + +Project-specific file/count/size/evidence requirements belong to these approved +external checks. Workstream's independent ZIP/resource and credential safety +bounds remain mandatory regardless of project configuration. Generated checker +implementations remain later development work; registering an implemented image +is distinct from asking a setup agent to generate or run contributor evaluation. Pre-submit checks are authoritative for intake. Post-submit runs supply the current evaluation evidence for TASK's locked-policy routing, not acceptance @@ -560,8 +413,7 @@ effective intake policy and compiled pre-submit policy; a separate operation then deterministically projects the same result's post-submit component. It does not resume finalized setup or invoke `PostSubmitCheckerPolicyDerivationAgent`. -Bindings select only implemented definitions/configurations from the exact -CHECKERS-owned catalogue snapshot and trace to the guide's requirement inventory. +Bindings select exact registered image/configuration/schema identities and trace to the guide's requirement inventory. All pre/post capability gaps block under the current compilation schema. Ordinary warnings may be acknowledged; unsupported automation cannot be silently reclassified as human review. Explicit approved `human_review` requirements @@ -571,8 +423,8 @@ acceptance both reject any applicable approved `human_review` disposition. Passing every executable check cannot discharge that separate requirement. The unified output is a constrained proposal. Workstream's trusted compiler owns the -canonical `PostSubmitCheckerPolicy.policy_body`, hash, default checker list, -and execution order. Runtime checker execution loads the locked compiled +canonical `PostSubmitCheckerPolicy.policy_body`, hash and ordered external +bindings. Workstream defaults belong to PRE, not a duplicated POST list. Runtime checker execution loads the locked compiled policy; it does not call the setup derivation agent to judge a contributor submission. A model-based runtime evaluator would be a separate supported, registered checker, not an automatic consequence of this setup flow. @@ -590,7 +442,9 @@ The first two gates replace external origin qualification and task ingestion for ## Project-Specific Checkers -Each project can register specialized checkers. +A system-scoped Operator registers specialized image implementations. Covered +Project Managers approve exact project bindings; a project grant alone grants +no registry publication authority. Examples: @@ -609,32 +463,19 @@ Examples: ## Post-Submit Compiler Boundary `PostSubmitCheckerPolicy` is produced by Workstream's trusted post-submit -compiler from a constrained specification. The compiler owns the canonical +compiler by projecting the exact approved external POST policy. The compiler owns the canonical runtime body and hash. Setup agents may propose registered checker names and routing classifications, but they do not produce executable runtime code and they do not decide submission outcomes at runtime. -The compiler always includes the platform default durable checkers in -`default_checkers` and `execution_checkers`: +Stored pre-cutover bodies preserve their original ordered entries and hashes +as immutable provenance, without a runtime decoder or replay executor. -- `check_submission_packet` -- `check_policy_context_present` -- `check_evidence_present` -- `check_evidence_integrity` -- `check_required_files` -- `check_forbidden_files` -- `check_confidentiality_attestation` -- `check_low_quality_generated_artifacts` - -Default-only projects are valid. The single compiled body stores ordered -entries; default, required, warning and execution lists are derived from them. -Project selections cannot remove, rename, reorder or reclassify mandatory -defaults. Only registered selectable entries may be added. Unknown or -conflicting selections reject before persistence. - -Platform blocking severities are `critical` and `high`. Project policy may add -stricter blocking severities, but it cannot remove those platform blocking -severities. +Those bodies are retained provenance only. New POST bodies contain the exact +approved external entries and `policy_sha256`; an empty approved POST binding +set is valid. Saved requirement, capability/version, stage and configuration +substitution rejects before projection. All required image results are consumed +by the selected outcome owner, without runtime catalogue lookup. ## Checker Run Flow — Target Contract @@ -646,14 +487,14 @@ invocation, recovers B6's exact request and fences both execution and terminal replay/publication. B8 delivers the hidden completion handler, and ARCH-04F2 extends its existing TASK/AUTH outcome operation so an exact current contributor-correctable result routes to `needs_revision` under either locked -ReviewPolicy mode. Both handlers remain unregistered; automatic production -delivery is unavailable. Known missing or corrupt input after ART authorization records a +ReviewPolicy mode. Selected request and completion handlers are registered in +production composition, with exact invocation authority and replay custody. Known missing or corrupt input after ART authorization records a terminal infrastructure failure only after cleanup and fresh finalization authorization; it does not route the task. Exact replay does not reread storage. Denied material access and unexpected failures remain nonterminal. ARCH-04F adds -replacement intake and authorized recovery after the delivered hidden -remediation handoff; those remaining boundaries gate public intake and enabling -the false-policy acceptance path. The true `allow_review` route may ship first. +implemented hidden exact-cause replacement intake and reason-bound Operator +retry after the remediation handoff. Public intake and false-policy guide +activation remain separate prerequisites for the complete public journey. ARCH-04B hidden input and ARCH-04B2 hidden output custody are delivered with output-file authority unavailable; exact input authority is implemented. The flow below is not a claim that the complete journey is live in production. @@ -661,18 +502,18 @@ The flow below is not a claim that the complete journey is live in production. ```text Draft packet -> load locked task context --> load locked EffectiveProjectSubmissionArtifactPolicy hash --> load locked PreSubmitCheckerPolicy compiled bundle hash --> run pre-submit intake checks +-> safely inspect ZIP in bounded private ART scratch +-> run the four Workstream default checks with server-verified digest/manifest +-> execute every approved external PRE image against verified read-only files -> create Submission only when blocking pre-submit checks pass -> automatically lock submission -> queue automatic pre-review CheckerRun -> validate locked PostSubmitCheckerPolicy id/version/hash/body --> execute locked PostSubmitCheckerPolicy execution_checkers +-> execute every exact locked external POST image -> store CheckerResult records -> calculate blocking status --> commit final CheckerRun result/recommendation, required verified output bindings and completion event --> TASK consumes the current result: publish allow_review manifest/current pointer and review_pending atomically when eligible +-> commit final CheckerRun result/recommendation, normalized JSON evidence and completion event +-> TASK consumes the current result: true -> review_pending; eligible locked false -> shared FinalAcceptance/TASK/CON/awards atomically -> TASK projects an exact current contributor-fixable failure as needs_revision with the retained manifest recommendation and no Review -> project/setup faults remain internal task_setup_blocked, never contributor blame -> if checker infrastructure fails: keep in checker retry handling @@ -681,8 +522,7 @@ Draft packet The checker run must bind to one immutable submission version. If the contributor uploads a replacement file, the platform creates a new submission version and reruns checks. `evaluation_pending` is the persisted state while post-submit checker execution -or infrastructure retry is active. After checker results, immutable output/log -artifact bindings, and completion facts commit atomically, the TASK-owned +or infrastructure retry is active. After checker results, normalized JSON evidence and completion facts commit atomically, the TASK-owned handler performs the routes above through its own transaction and authority: passing work moves to `review_pending`, while contributor-fixable blocking failures may move to `needs_revision`. The routing @@ -704,14 +544,15 @@ Submission/result/manifest/receipt lineage, and creates no Review, FinalAcceptance, ContributionRecord or award in either ReviewPolicy mode. Exact replay returns the retained outcome without repeating effects. Contributors continue to receive only the existing bounded checker-history projection; no new -public API is added. Replacement intake, setup-fault and Operator recovery, -production composition and public intake remain required before public cutover. +public API is added. Exact-cause replacement and Operator retry are implemented hidden owners; +manager-visible setup-fault hold and public intake remain before public cutover; selected production +composition is implemented. The direct CHECKERS-to-TASK mutation, fabricated system actor and alternate -Celery gate are removed. Hidden durable execution and exact service authority are implemented; automatic -routing remains unregistered. ARCH-04E2-B supplies the hidden authorized +Celery gate are removed. Hidden durable execution and exact service authority are implemented; selected automatic +request/completion delivery is registered. ARCH-04E2-B supplies the hidden authorized outcome operation; B8 supplies hidden completion delivery; ARCH-04F2 extends -that operation with remediation. Live composition and the remaining 04F -replacement/recovery boundaries remain pending. +that operation with remediation. Manager-visible setup-fault hold, public intake and public false-guide activation +remain pending. `review_pending` marks readiness for the separately owned WS-REV lifecycle. WS-REV alone creates `ReviewPacketManifest`, review queues, reviewer leases, @@ -726,10 +567,10 @@ and there is no Review or review decision ID. Human review can also produce If a checker crashes or cannot run because of platform infrastructure, the checker run remains failed as an infrastructure failure and the task does not -move to human review. The planned retry contract requires Operator +move to human review. The implemented hidden retry contract requires Operator `operations.checker.retry`, a reason, a new attempt/supersession record, and -append-only audit evidence. That action is currently unavailable; no public -retry or repair route survives the alternate execution removal. +append-only audit evidence. That action is available only through the canonical hidden recovery owner; +no public retry or repair route is added. This terminal retry is distinct from transport redelivery or recovery of an unfinished provider call. In-flight recovery retains its logical attempt and @@ -793,8 +634,8 @@ locked post-submit policy body, or hidden task setup details. ARCH-04C retains closed, ordered post-submit checker identity and implementation version, status, code, failure category, severity and the bounded counter fields of `PostSubmitMemberResult`. It also retains exact request/result generation and -digests, locked policy lineage and verified material custody. Current structural -handlers emit empty counters. Explanations and suggested fixes in history are +digests, locked policy lineage and verified material custody. Retained structural +results have empty counters; selected image results use their closed JSON schema. Explanations and suggested fixes in history are derived deterministically from the closed code; raw handler messages and arbitrary metadata are not retained. @@ -821,15 +662,14 @@ ARCH-04C adds neither full-log reads nor retry/repair controls. ## Recovery, Not Checker Override -`operations.submission_gate.repair` and `operations.checker.retry` are planned, -unavailable actions. The following describes their required future authority -and evidence contract, not existing callable recovery routes. +`operations.checker.retry` has a hidden reason-bound Operator owner with fresh +authority and immutable attempt/audit custody. `operations.submission_gate.repair` +remains unavailable. Neither grants a public override or bypasses checker results. Critical- and high-severity checker failures cannot be converted into review readiness by an administrative grant. A covered Project Manager may repair task -setup under `project.task.manage`. Future Operator submission-gate repair and -checker retry must be limited to the registered recovery purpose once their -canonical owner implementation and AUTH activation are delivered. +setup under `project.task.manage`. Future Operator submission-gate repair and current hidden checker retry must +remain limited to their exact registered recovery purposes. Recovery requires: @@ -934,22 +774,24 @@ No manual execution endpoint accompanies these reads. See the ### Shared evaluation content capacity -ARCH-04E1B-B5 provides `PostSubmissionEvaluationContent` for both preparation and -later identified requests. It enforces the existing locked catalogue, policy -consistency and structural field limits, plus 1 MiB minus a 1,024-byte reserve -for the finite request identity envelope. The full request retains its 1 MiB -limit and canonical digest. Catalogue and policy identities are unchanged. - -The typed adapter projects every verified file and only required evidence with -matching verified `evidence/{key}` files. Missing criteria remain empty text for -the existing post-submit checker. This check does not execute evaluators or -supply authority. See [the ART admission boundary](spec_artifact_storage_service.md#evaluation-content-capacity-before-durable-admission) -for timing and cleanup. ARCH-04E1B-B6 reuses this projection with actual record -identities and reserves generation one through the required TASK guard in the -Submission transaction. The same commit retains exact creation/binding AUTH -receipts and a bounded `PostSubmissionEvaluationRequested` outbox event. -Select-only creation replay checks the original request and event without -changing the current-generation fence. Request/completion handlers remain -unregistered; this stored intent alone does not invoke checker execution or accept work. +ART checks the canonical selected POST content against the request capacity +before durable admission. The content binds the exact approved external policy, +verified artifact/manifest, and checked packet; the removed catalogue content +type and preparation adapter are not retained as executable alternatives. +See [the ART admission boundary](spec_artifact_storage_service.md#evaluation-content-capacity-before-durable-admission). + +The existing Submission transaction reserves generation one through the TASK +guard and retains exact creation/binding AUTH receipts and a bounded +`PostSubmissionEvaluationRequested` outbox event. Exact creation replay checks +the original request and event without changing the current-generation fence. +The selected request/completion handlers consume those facts. Stored intent +alone confers no execution or acceptance authority. Preparation has no stored Submission to observe and never replaces the TASK evaluation guard. + +External PRE and POST bindings may carry the same bounded, flat typed parameters. +Approval validates their JSON values against the exact registered configuration +schema and retains both the values and configuration digest in the locked policy. +Empty PRE parameters remain absent from serialized bindings so reading historical +compilation evidence does not change its original hash. Array values serialize +as JSON arrays before schema validation and digest computation. diff --git a/docs/architecture_lockdown.md b/docs/architecture_lockdown.md index 9cc040ab0..5d80e3bed 100644 --- a/docs/architecture_lockdown.md +++ b/docs/architecture_lockdown.md @@ -185,7 +185,13 @@ Project Manager approves the internal policy bundle before guide activation. Project owners do not approve Workstream's internal submission policy schema. -`SubmissionArtifactPolicy` defines project-level intake rules. Workstream combines it with the non-bypassable Workstream default submission artifact policy to create `EffectiveProjectSubmissionArtifactPolicy`. Workstream then generates, persists, and locks project `PreSubmitCheckerPolicy` with a compiled bundle hash from that effective project submission artifact policy. Tasks lock the applicable guide snapshot, effective project submission artifact policy hash, and pre-submit checker bundle hash before entering the contributor pipeline. +Tasks lock the approved guide and its exact PRE/POST image policies before +entering the contributor pipeline. Workstream provides one versioned default +checker: safe ZIP/resource bounds, server-verified digest and manifest, +high-confidence secret rejection, and unchanged-work rejection unless locked +rules changed. Missing summary or attestation produces warnings only. All +project checks are approved, digest-pinned external images; there is no built-in +project-rule catalogue or alternate checker runtime. Blocking pre-submit failures prevent submission creation. The mounted authorized hidden preparation route returns `pre_submission_checker_failed` @@ -196,30 +202,26 @@ submission-created audit event. ART constructs a bounded audit-ready projection; publication as a task event named `pre_submission_check_failed` remains pending. That event must contain only this closed, path-redacted projection: actor-profile ID, project ID, task ID, preparation attempt ID, effective-plan hash, terminal status, pass/warning/failure counts, -and a bounded ordered list of catalogue ID/version plus stable outcome code. +and bounded ordered default or registered-image identities plus stable outcome codes. It excludes filenames, archive paths, scratch/provider references, credentials, raw checker output, evidence content, and free-form or unbounded messages. This -is audit evidence, not a product review decision. POL-07B removes the independently invocable -JSON precheck. The internal pre-submit phase command uses ART's existing -execution or completed-evidence recovery after reservation commits. WS-ARCH-001-02I retains the -broader Submission caller migration and its downstream prerequisites. - -Hidden ART-04B2 uses XINT-06A's two-stage fixed-materializer PREP. It locks the -service/action and scalar lineage before ZIP inspection, then consumes the same -process-local handle with the server-computed semantic-manifest identity before -workspace reservation or checker execution. It reserves the complete expanded -workspace budget, verifies one canonical sealed tree against the 04A identities, -and runs only platform/default catalogue entries. The callback-scoped tree and -all paths are destroyed before bounded results return. Hidden ART-04B3 extends -that same plan and sealed-tree callback -to the locked project-policy entries, then reloads the exact task context and -persists one immutable platform-plus-project evidence set after scratch cleanup. -It creates no provider object, admission, Submission, or lifecycle effect; -AUTH activation remains XINT-06A. POL-07A commits an ART execution reservation -before invoking checks, then obtains fresh materialization authority; a prepared -AUTH handle never crosses that commit. Completed replay freshly authorizes the -stored original generation and returns evidence without another checker run or -pass capability. +is audit evidence, not a product review decision. + +ART safely expands the ZIP in bounded private scratch and independently verifies +the bytes and manifest. The default checker runs before any project PRE image. +Each image receives verified files read-only through the Rust service and its +callback-scoped grant. CHECKERS commits its execution lease before material work; +TASK custody precedes CHECKERS locks, and no authority transaction stays open +through image execution. Scratch is destroyed before finalization revalidates +the exact assignment, locked policy, material and lease. + +Only a complete PRE pass permits the existing caller-owned atomic transaction to +consume admission and commit an immutable Submission, `evaluation_pending`, and +the initial POST dispatch together. Work failures return bounded findings, +create no Submission, and leave the task `in_progress`. Infrastructure failures +remain recoverable. POST images evaluate immutable Submission material and +produce evidence for the existing governed routing operation. Exact replay +requires fresh authority and returns original immutable facts. Tasks lock to the active guide version at creation or screening time before entering `READY`. Material guide changes require a new guide version. @@ -317,19 +319,16 @@ metadata only. Decision and contribution creation perform no ART call. ### Pre-Submission Checker Boundary -CHECKERS owns the single deterministic effective pre-submission plan. The 02C -boundary exposes its public planning port and bounded result facts and migrates -the touched TASK and ART admission call path away from the catalogue, compiler, -and executor internals. ART retains exclusive ownership of byte custody, -storage scheme, evidence persistence, pass capability, and admission attachment. - -Public CHECKER results contain the plan identity, eligibility outcome, and -bounded per-definition facts only. They never expose prepared-artifact custody, -scratch state, provider details, or durable evidence identity. Concrete -catalogue and executor implementations remain CHECKER-private and meet callers -only through application composition. Existing ART materialization and evidence -imports that have not yet migrated remain frozen private-edge debt; the next -ART boundary chunk must move those exact consumers to this public surface. +CHECKERS owns the default-plus-external PRE contract, exact approved image +bindings and durable run/lease/result custody. ART owns verified bytes, bounded +scratch, immutable storage and admission attachment. Callers use typed public +ports through application composition. Both executable catalogues and their +old request/result decoders are removed. + +Contributor feedback contains bounded default/image findings and eligibility. +It conceals scratch state, provider coordinates, credentials and raw checker +output. The hidden preparation flow is implemented; public contributor +upload/finalize and CLI submit/status remain PILOT-12 work. ### Contribution Records diff --git a/docs/decision_0014_external_service_adapter_convention.md b/docs/decision_0014_external_service_adapter_convention.md index 078378f18..10bc7fe7c 100644 --- a/docs/decision_0014_external_service_adapter_convention.md +++ b/docs/decision_0014_external_service_adapter_convention.md @@ -38,7 +38,7 @@ ExternalServiceAdapter |- IdentityIssuerVerifier |- ProjectGuideAgentRuntime |- ArtifactStore -`- ExternalCheckerExecutionAdapter (hidden, unselected Unix-socket runtime) +`- ExternalCheckerExecutionAdapter (selected hidden Unix-socket runtime) ``` Concrete implementations satisfy explicit capability ports: @@ -60,10 +60,11 @@ The checker capability publishes a normalized pre/post request/result contract, an immutable digest-pinned metadata registry, and a hidden typed Unix-socket adapter for its trusted Rust node service. The registry remains data, not an adapter factory: it cannot select Python code, discover plugins, grant execution -authority or construct a transport. The adapter factory is instance-local and -unselected; no product composition root or checker caller installs it yet. -Later cutover must preserve CHECKERS request/lease/result ownership and ART -material custody when it adds that explicit composition. +authority or construct a transport. The adapter factory is instance-local and explicitly installed by the selected +hidden CHECKERS composition. Durable PRE/POST owners retain request, lease and +result custody, while ART owns verified material and callback-scoped grants. +Production request/completion registration does not expose public intake. +Local integration uses runc/docker-dev; hosted gVisor proof remains separate. The Rust service alone owns container-engine access. Python sends an opaque, request-bound callback grant issued from ART's existing pre-admission scratch diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 6b32c26e6..f0ce45220 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -1311,14 +1311,19 @@ closes SQL before publishing event/project selectors. Failed publication is rediscovered; expired claims are recovered even if their handler was removed. Each delivery phase uses fresh AUTH/PREP and retains its exact decision reference. Unknown invoked effects remain terminal for reconciliation. The production -registry contains only `TaskAssignmentAuthorityInvalidationRequested` version 1, -with its separate exact feature authority. Unregistered event types stay unclaimed. +registry contains assignment invalidation and the selected post-submit request +and completion events, each with its separate exact feature authority. +Unregistered event types stay unclaimed. Celery worker retries cover infrastructure failure with at most three retries and a 30-second exponential delay; they never authorize repeating an invocation. -Delivery requires Celery's prefork process pool. `deliver_event` has a 300-second -hard execution limit, independent of its 240-second handler timeout and covering -async shutdown as well. The prefork parent acknowledges a hard timeout instead +Delivery requires Celery's prefork process pool. `deliver_event` has an exact +3,660-second hard execution limit, covering the maximum 3,600-second delivery +lease plus a fixed 60-second process-shutdown margin. Selected post-submit +requests have a 3,520-second handler timeout; completion and assignment +invalidation retain their 240-second handler timeout and 300-second lease. +Task or broker-message overrides that change the hard limit are refused before +SQL. This process limit also contains cancellation-resistant async shutdown. The prefork parent acknowledges a hard timeout instead of requeuing that timed-out invocation. A committed UNKNOWN remains terminal; if termination interrupts finalization, the existing expired-invocation recovery records UNKNOWN without calling the handler again. Ordinary Celery worker-loss diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 253f147b7..4ff3a94d4 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -53,8 +53,9 @@ outside v0.1. ## Status Vocabulary -The automated-acceptance branch is a **planned** v0.1 -requirement, not a live capability. The existing versioned ReviewPolicy now +The complete public automated-acceptance journey is a **planned** v0.1 +requirement. Selected hidden completion supports the false-policy outcome; +public false-policy guide activation remains blocked pending PILOT-08. The existing versioned ReviewPolicy now persists the setting: `human_review_required: bool = true`. True requires human review after required checks pass; false leads to authorized FinalAcceptance and submitter contribution without a reviewer contribution. It requires explicit locked project policy, @@ -74,8 +75,8 @@ belongs to later fulfillment activation. The setting is configurable through the existing authorized policy writer; creation defaults true and omitted replacements inherit the predecessor. -Versioned hashing preserves old policy hashes and locks. False remains blocked -by the current activation service. See the [implementation record](../.commitrail/changes/pre-review-plan-reconciliation.md#delivered-policy-setting-implementation). Enabling false follows shared final-acceptance, +Versioned hashing preserves old policy hashes and locks. Public false-policy guide activation remains blocked +by the current activation service pending PILOT-08. See the [implementation record](../.commitrail/changes/pre-review-plan-reconciliation.md#delivered-policy-setting-implementation). Enabling false follows shared final-acceptance, TASK effects, exact AUTH/source custody and routing proof, not live human-review infrastructure. The separate `requires_second_review` field is [fixed to false](../.commitrail/changes/enforce-requires-second-review-false.md) at typed input, immutable lineage and PostgreSQL boundaries. Retained true facts @@ -116,7 +117,7 @@ contributor-correctable result through the existing TASK/AUTH operation to `needs_revision` under either locked ReviewPolicy mode. 04F3 adds hidden exact-cause replacement intake through the canonical ART/TASK writer, preserving the predecessor, locked policy and checker source while atomically creating the -successor Submission and evaluation request. ARCH-04F4 adds hidden reason-bound Operator retry for an exact current terminal `infrastructure_failed` run through the existing CHECKERS coordinator and request handler, with immutable source, AUTH/audit/outbox custody and fresh-authorized historical replay. Manager-visible setup-fault hold remains. Production registration remains unavailable, and no public API is added. +successor Submission and evaluation request. ARCH-04F4 adds hidden reason-bound Operator retry for an exact current terminal `infrastructure_failed` run through the existing CHECKERS coordinator and request handler, with immutable source, AUTH/audit/outbox custody and fresh-authorized historical replay. Manager-visible setup-fault hold remains. PILOT-04 registers selected request and completion handlers in production composition; no public intake API is added. Exact assignment-invalidation authority, atomic originating publication and production delivery with enforced prefork topology are implemented. Manager task @@ -147,7 +148,7 @@ contracts. Required success branches on the locked ReviewPolicy: the hidden operation stages the TASK human-review handoff for true and shared authorized acceptance without a human Review for false. These internal outcomes have transaction and database proof; B8 supplies hidden completion delivery, and -guide activation still rejects false until its runtime prerequisites are met. ART-07A1 supplies metadata-only reviewer packet types, with no resolver or byte +public guide activation still rejects false pending PILOT-08. ART-07A1 supplies metadata-only reviewer packet types, with no resolver or byte authority. REV-03B packets and REV-04A immutable Review source storage are delivered; REV-04B shared acceptance storage, CON-03C contribution/award storage, REV-12A1 disabled controller/fence mechanics and CON-07 hidden source-neutral submitter participation with complete frozen award sets are delivered. [REV-04C](../.commitrail/initiatives/WS-REV-001/WS-REV-001-04C.md) adds one hidden source-neutral participant that stages @@ -165,7 +166,7 @@ actual receipt custody with `checker_remediation`: TASK moves from creates no Review, FinalAcceptance, ContributionRecord or award under either ReviewPolicy mode. 04F3 admits a changed full ZIP from that exact current authorized cause under either mode, with no failure economics or human Review. -04F4 adds a separate hidden Operator retry for known terminal infrastructure failure; it creates no Review, acceptance, contribution or award. Manager-visible setup-fault hold, production registration and false-guide activation remain unavailable. Human review/revision, conditional fulfillment, operations and +04F4 adds a separate hidden Operator retry for known terminal infrastructure failure; it creates no Review, acceptance, contribution or award. Manager-visible setup-fault hold and false-guide activation remain unavailable; selected production request/completion composition is implemented. Human review/revision, conditional fulfillment, operations and release proof remain later v0.1 work. The [independent MCP package](../mcp_server/README.md) implements nine self-service @@ -256,8 +257,8 @@ implementation and policy binding and is not claimed live here. | Stage | Purpose and examples | Policy and execution boundary | Outcome | | --- | --- | --- | --- | -| Pre-submission intake checks | Is this package acceptable to submit? Check completeness, required/forbidden files, evidence integrity, and configured intake-quality rules. | The locked `PreSubmitCheckerPolicy` and effective artifact policy drive the pre-submission catalogue during continuous artifact preparation, before a Submission exists. | Blocking failures return correction feedback and prevent Submission creation. Passing intake does not prove the task is accepted or ready for review. | -| Post-submission evaluation | Does the submitted work meet the configured task/project checks? Evaluate the exact stored work and evidence under the locked requirements. | The Submission-stamped `PostSubmitCheckerPolicy` drives the current in-process structural-handler registry and durable `CheckerRun` execution after immutable Submission creation; this path is distinct from the new external-image metadata registry. Only supported, registered checks execute. 04F1 verifies current completed failure evidence through the same CHECKERS port. | Persist current evidence. The hidden authorized operation publishes exact source custody. `allow_review` branches on locked ReviewPolicy: true moves TASK to `review_pending`; false invokes shared acceptance under TASK authority. Exact current `needs_revision` moves TASK to contributor remediation under either mode without REV/CON effects. CHECKERS never writes TASK state or acceptance itself. B8 supplies hidden completion delivery; automatic production registration remains pending. | +| Pre-submission intake checks | Is this package acceptable to submit? Check completeness, required/forbidden files, evidence integrity, and configured intake-quality rules. | The locked selected PRE policy runs the single Workstream default followed by every required digest-pinned project image on ART-verified material, before a Submission exists. | Blocking failures return correction feedback and prevent Submission creation. Passing intake does not prove the task is accepted or ready for review. | +| Post-submission evaluation | Does the submitted work meet the configured task/project checks? Evaluate the exact stored work and evidence under the locked requirements. | The Submission-stamped external POST policy drives approved digest-pinned images through the Rust service and durable `CheckerRun` ownership after immutable Submission creation. Only supported, registered checks execute. 04F1 verifies current completed failure evidence through the same CHECKERS port. | Persist current evidence. The hidden authorized operation publishes exact source custody. `allow_review` branches on locked ReviewPolicy: true moves TASK to `review_pending`; false invokes shared acceptance under TASK authority. Exact current `needs_revision` moves TASK to contributor remediation under either mode without REV/CON effects. CHECKERS never writes TASK state or acceptance itself. B8 supplies completion custody; PILOT-04 registers selected request/completion production delivery. | The unified guide agent proposes both sets of policy bindings in one setup result. Trusted compilation, validation, and the governing approval path turn @@ -285,17 +286,17 @@ cannot be reused as post-submission review-gate evidence. See the | 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; 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 | Public intake cutover after evaluation/remediation prerequisites | | 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; 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; bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; 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 | Public intake cutover follows evaluation/remediation prerequisites | | Bulk task source custody | **Public backend source foundation delivered** | PILOT-02A provides covered-PM project-bound declaration, canonical 1..500-row JSON upload with exact SHA/count, fresh-authority replay, source status and independently verified download using existing ART custody; linear migration and public-route PostgreSQL/MinIO custody, rollback, concurrency and recovery tests pass | Atomic DRAFT import, separate explicit batch screen/release and CLI conversion; issue 489 remains open | -| Contributor artifact preparation | **Hidden and proven** | One outer ZIP; bounded scratch inspection; canonical manifest; shared evaluation-content capacity and exact locked-policy checks before attempts or durable intent; platform and project prechecks; unchanged-work rejection; durable put intent; verification; capacity-charged ready admission; hidden final handoff validates the exact activated historical guide through owner ports; 04F3 reuses full intake for exact-cause checker-remediation replacement | Complete the later public admission-only cutover | -| Pre-submission intake checking | **Hidden with approved-guide lineage and same-request feedback; external contract and unselected runtime mechanism delivered** | Separate versioned pre-submission catalogue, locked effective-plan compilation, platform/project checks during continuous preparation, and one internal phase command covering execution/replay with the JSON precheck removed; the mounted authorized hidden route returns exact ordered bounded feedback for blocking findings before Submission creation while remaining absent from OpenAPI. ARCH-03D connects approved-guide lineage through the final durable handoff. PILOT-04 publishes the strict normalized external pre-submit identity, immutable authorized digest-pinned registry, unselected Rust service/SDK and ART callback grant for verified read-only material without routing a product request | Implement one Workstream default checker containing exactly the four approved blocking behaviors; bind and route every required digest-pinned project pre-image only after verified ZIP identity/manifest; require all prechecks to pass before the caller-owned atomic Submission/`evaluation_pending`/initial-dispatch transaction. Work findings create no Submission and leave the task `in_progress`; infrastructure failures remain recoverable. Bind approved policies, prove F-020 recovery and public intake integration, then remove both legacy catalogues without a parallel path | -| Immutable Submission creation | **Hidden foundation; public packet creation retired** | Contributor preparation authority; durable pre-submit reservation and exact completed-evidence recovery without rerunning checks; atomic admission consumption; TASK-owned admission-backed creation with exact assignment ContributionPolicyVersion, locked policy lineage and service/database checked-packet custody; 04F3 exact predecessor/cause/receipt closure for hidden replacement; fixed-service artifact binding; replay/concurrency/rollback proof | Finish downstream evaluation and the canonical public integration. The retained submission-list GET is not a usable creation POST | -| Post-submission evaluation and governed outcome | **Hidden authorized outcomes; external contract, registry and unselected runtime mechanism delivered; automatic production routing unavailable** | PILOT-04 supplies normalized external checker contracts, an immutable authorized OCI-digest registry, and an unselected Rust service/SDK with a typed Unix-socket adapter and ART callback grant, without product composition or policy authority. Exact ART input/output custody, durable current checker results, actual input/execute/finalize receipts, TASK request/source reservation and B7 hidden request delivery; 04E3A gives that hidden handler the existing Celery ART provider lease with invocation-scoped scratch; 04E2-B binds real route AUTH, source publication and complete true/false success consequences with database closure, shared audit/outbox and exact replay; B8 wires hidden completion delivery to the committed operation; 04F1 verifies current completed failure evidence and 04F2 routes exact current contributor-correctable results to `needs_revision` under both policy modes with no REV/CON effect; 04F3 admits exact-cause replacement ZIPs under either mode through the canonical writer and preserves the predecessor, immutable evidence and receipts; 04F4 adds hidden Operator retry of a known terminal infrastructure failure with exact AUTH decision, audit, retry receipt, successor request event and fresh-authorized historical replay, while the failed run remains immutable. | Bind and execute digest-pinned external checkers with replacement proof before removing the superseded catalogue; registry and hidden runtime foundations alone do not activate execution. Complete manager-visible setup-fault hold after 04F3 hidden exact-cause replacement intake and 04F4 hidden Operator retry, before 04E3 production registration and false-guide activation. Retain the Submission and locked rules without contributor penalty; changing or re-screening submitted-task rules remains deferred. True handoff has no REV/CON prerequisite. No public intake or live human review/revision is supplied. | +| Contributor artifact preparation | **Hidden and proven** | One outer ZIP; bounded scratch inspection; canonical manifest; shared evaluation-content capacity and exact locked-policy checks before attempts or durable intent; platform and project prechecks; unchanged-work rejection; durable put intent; verification; capacity-charged ready admission; hidden final handoff validates the exact activated historical guide through owner ports | Complete the later public admission-only cutover | +| Pre-submission intake checking | **Selected hidden flow with approved-guide lineage and bounded feedback** | One versioned Workstream default checker enforces safe ZIP/resource bounds, independently verified digest/manifest, high-confidence secret rejection and unchanged-work rejection. Missing summary/attestation produces warnings only. Approved project PRE checks are immutable digest-pinned images run by the Rust service on verified read-only ART material. Durable selected custody takes TASK before CHECKERS; only a complete pass grants admission to the existing atomic Submission transaction. Both executable catalogues are removed | Public contributor upload/finalize and CLI submit/status remain PILOT-12. Work findings create no Submission and preserve in_progress; infrastructure failures retain recoverable outcomes. Local Rust/MinIO proof uses runc/docker-dev; hosted gVisor integration and representative limits remain | +| Immutable Submission creation | **Hidden foundation; public packet creation retired** | Contributor preparation authority; durable pre-submit reservation and exact completed-evidence recovery without rerunning checks; atomic admission consumption; TASK-owned admission-backed creation with exact assignment ContributionPolicyVersion, locked policy lineage and service/database checked-packet custody; fixed-service artifact binding; replay/concurrency/rollback proof | Finish downstream evaluation and the canonical public integration. The retained submission-list GET is not a usable creation POST | +| Post-submission evaluation and governed outcome | **Selected hidden external execution and production completion routing** | Immutable approved image bindings, Rust service/SDK and typed ART callback grants; exact verified input custody, bounded normalized JSON results and phase authority. Production outbox request/completion handlers run selected images and preserve exact restart/replay. True-policy success reaches review_pending; false-policy success invokes shared authorized FinalAcceptance and atomically creates the submitter ContributionRecord and complete frozen award set. Contributor-correctable failures retain exact remediation causes; Operator retry retains fresh authority and no spent contributor round | Public intake remains PILOT-12; live human review and contributor revision surfaces remain separate. Uncovered required guide capabilities block activation; registration alone cannot repair an approved generation. True handoff has no REV/CON prerequisite. No deployed runtime or exhaustive public journey claim | | Review queue and lease | **Hidden persistence foundation** | Queue/admission idempotency and ReviewLease/preference persistence; complete unavailable REV action/principal catalogue and typed AUTH contracts; ART-07A1 metadata-only packet contract and REV-03B immutable normalized packet persistence with live ingest custody; REV-04A Review/finding/resolution and completed request storage | Future resolver proof; canonical admission from `allow_review`; claim/lease/packet authority; lease copies the Submission-stamped policy version with no CON lookup | | Review decision and revision | **Hidden acceptance core; runtime planned** | Review/revision policy identities and mutation authority, with `requires_second_review` fixed false through typed and database boundaries; REV-04A immutable Review storage; REV-04B source storage; REV-04C hidden source-neutral FinalAcceptance/TASK/CON participant; approved same-task revision-rebase semantics | ARCH-04E2-B supplies mandatory source AUTH and complete database/audit/outbox closure for hidden automated outcomes. Human decision authority/composition, revision preparation, replay and recovery remain | -| Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings; public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction; 04F2 checker remediation creates no contribution or award | ARCH-04E2-B supplies exact routing authority/evidence, complete-set database closure and audit/outbox under REV-12A4A. Hidden B8 completion delivery, 04F3 exact-cause replacement and 04F4 hidden retry are implemented; manager-visible setup-fault hold and live composition remain before production consumption. Only actual Reviews create reviewer records. Obligation/root/ordinal custody remains deferred until fulfillment activation; no public recognition or fulfillment route is live | +| Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings; public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction; 04F2 checker remediation creates no contribution or award | ARCH-04E2-B supplies exact routing authority/evidence, complete-set database closure and audit/outbox under REV-12A4A. Selected production request/completion delivery, 04F3 exact-cause replacement and 04F4 hidden retry are implemented; manager-visible setup-fault hold and the complete public journey remain. Only actual Reviews create reviewer records. Obligation/root/ordinal custody remains deferred until fulfillment activation; no public recognition or fulfillment route is live | | Contribution evidence reads | **Public bounded backend reads** | PILOT-10A exposes current human self-read and one-project JSON/CSV export for covering Project Managers or exact-project Finance Authorities; one fresh repeatable-read response with one narrowly bounded serialization-conflict retry and fresh authority, complete zero/one/two immutable awards, exact decimals, recorded-time filters and formula-neutralized CSV; 1,000-contribution maximum refuses excess without truncation | CLI consumption and stable fixed-membership paginated list remain separate; no recognition mutation, fulfillment, payout or deployed-service claim | | Fulfillment, reconciliation, and audit | **Planned** | Shared audit foundations, provider-neutral adapter convention, AUTH-OUTBOX-02 live dispatcher authority, retained phase audit decisions, Celery delivery/recovery scans and CON-02B custody | Feature-specific handlers and authority, conditional award fulfillment, callbacks, idempotent recovery, reconciliation, bounded operational reads, and release controls | | Runtime diagnostics | **Implemented foundation** | Closed structured logging, explicit API/Celery tracing, bounded metrics, safe correlation and optional typed OTLP export | Restrict broker publishers; configure and secure collector/log access, egress, encryption and finite deletion; prove diagnostics during the release drill; no deployed monitoring claim | -| Frontend and pilot | **Local runtime foundation; product pilot planned** | Checkout-isolated Docker Compose API, prefork Celery process, beat, PostgreSQL, Redis and MinIO stack with local Flow-HMAC identities, existing authority bootstrap/grants, configurable loopback ports and project-scoped state; a pinned offline sample image build and separate oracle were proved under gVisor; React + Vite + TypeScript stack decision | Implement the external launcher/checker images and benchmark a representative task before choosing production limits; complete provider-backed guide compilation and activated-guide scoped task-denial proof on the base stack, implement only stable backed frontend surfaces, run the real internal pilot, repair findings, and complete release drills; Docker Desktop/macOS runtime proof remains | +| Frontend and pilot | **Local runtime foundation; product pilot planned** | Checkout-isolated Docker Compose API, prefork Celery process, beat, PostgreSQL, Redis and MinIO stack with local Flow-HMAC identities, existing authority bootstrap/grants, configurable loopback ports and project-scoped state; a pinned offline sample image build and separate oracle were proved under gVisor; React + Vite + TypeScript stack decision | Benchmark representative project checker images and prove hosted isolation before choosing production limits; complete provider-backed guide compilation and activated-guide scoped task-denial proof on the base stack, implement only stable backed frontend surfaces, run the real internal pilot, repair findings, and complete release drills; Docker Desktop/macOS runtime proof remains | ## What Has Been Completed @@ -572,7 +573,7 @@ cannot be reused as post-submission review-gate evidence. See the post-submit proposals, then projects the sufficiency and artifact-policy components and atomically finalizes the setup at draft output. A blocked guide ends at sufficiency evidence, with no policy projection. Its canonical result - retains exact supported catalogue matches and one evidence-linked engineering + retains exact registered digest-pinned image matches and one evidence-linked engineering suggestion for each required missing pre-submit or post-submit check. Fully covered projects need no suggestions. POL-05B exposes the complete POL-05A review package; AUTH-12F4 supplies exact manager authority; suggestions cannot register or activate a checker. Runtime, model/provider and instructions are independently @@ -682,8 +683,10 @@ owns implementation sequencing. The existing checker phase service supports hidden pre-submit execution/replay and hidden durable post-submit execution. Exact post-submit service authority and the hidden 04E2-B authorized outcome operation are implemented. 04F2 adds the hidden policy-independent remediation handoff, 04F3 adds -hidden exact-cause replacement intake and 04F4 adds hidden Operator retry; automatic production delivery and public -acceptance remain unavailable. +hidden exact-cause replacement intake and 04F4 adds hidden Operator retry. PILOT-04 +registers selected request and completion delivery in production composition; passing +false-policy results use the shared atomic acceptance operation. Public contributor +upload/finalize remains PILOT-12 work. Live setup does not wait for downstream task/checker execution. The [first complete contributor milestone](../.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone) @@ -746,7 +749,7 @@ The broader v0.1 sequence below retains later scope: an explicitly implemented model judge. A structural presence check cannot stand in for a required substantive quality evaluation. Unsupported required evaluators block the affected guide until implemented and included in a new - approved catalogue-bound generation. Infrastructure retries and project + approved image-bound generation. Infrastructure retries and project setup faults are not contributor failures; `allow_review` is not acceptance. **For the first false-policy acceptance path:** consume the delivered TASK 04E1A source facts through the delivered REV-04B source FK; TASK request/source-identity reservation (04E1B-A) and the hidden @@ -838,19 +841,21 @@ ARCH-04D2 supplies exact service authority; 04E/B8 supply hidden authorized routing and completion delivery, 04F2 supplies the hidden remediation branch, 04F3 supplies exact-cause hidden replacement intake, and 04F4 supplies hidden Operator retry of terminal infrastructure failure. -Production registration remains unavailable. +PILOT-04 registers the selected request and completion handlers in production composition; this does not expose public intake. ARCH-04B adds hidden exact Submission materialization through TASK's immutable read port and ART's consumed admission. Local/MinIO input is independently reread, verified and projected through canonical bounded scratch. ARCH-04D2 supplies exact leased input authority before and after I/O. -Public intake and automatic checker dispatch remain unavailable. +Public intake remains unavailable; selected hidden Submission creation durably +dispatches the initial post-check request. ARCH-04B2 adds hidden typed checker-output `store` and byte-free `recover(selector)` operations, per-slot preparation caps, generic put and verification reuse, and flush-only immutable verified binding. Controlled -nonempty slots prove ART mechanics only; the current structural catalogue has -zero slots. ARCH-04C supplies CHECKERS reservation/currentness; ARCH-04D2 supplies -execute/finalize authority. Output write/bind authority remains unavailable. Public intake is not activated. +nonempty slots originally proved ART mechanics only. Selected external +images return bounded schema-validated JSON results; generated output-file +write/binding authority remains unavailable. ARCH-04C supplies CHECKERS reservation/currentness; ARCH-04D2 supplies +execute/finalize authority. Public intake is not activated. ## Critical Dependency Map @@ -892,7 +897,7 @@ Delivered foundations (not a claim of full public integration) ARCH-04E1B-B1 ordered reservation/current reads + admission INSERTs (no activation) ARCH-04E1B-B2 exact source proposal + B3 ZIP metadata + B4 checked packet custody + B5 bounded evaluation content ARCH-04E1B-B6 atomic Submission/dispatch + exact AUTH receipts + replay (no delivery/publication authority) - ARCH-04E1B-B7 hidden request delivery (unregistered) + ARCH-04E1B-B7 original hidden request-delivery foundation REV-12A4A scoped Operator transitions + immutable AUTH/history custody + current-generation gates PILOT-02A public canonical JSON source custody + authorized replay + PostgreSQL/MinIO recovery proof @@ -905,7 +910,9 @@ Delivered foundations (not a claim of full public integration) ARCH-04F2 exact current checker remediation through TASK/AUTH under both policy modes ARCH-04F3 hidden exact-cause replacement through canonical ART/TASK intake and writer ARCH-04F4 hidden reason-bound Operator retry through CHECKERS/AUTH + existing request handler - ARCH-04E3A hidden ART Celery child lease + per-invocation scratch; no production registration + ARCH-04E3A hidden ART Celery child lease + per-invocation scratch + PILOT-04 selected default + exact external PRE/POST images; both catalogues removed + production request/completion handlers -> true review_pending or hidden false acceptance PILOT-10A public bounded evidence reads over existing immutable CON/awards active human self or covering PM / exact-project Finance -> JSON or CSV @@ -915,9 +922,9 @@ Remaining integration PILOT-02 separately scoped atomic DRAFT import -> explicit batch screen/release Manager-visible setup-fault hold: retain Submission/locked rules, no contributor penalty (changing submitted-task rules remains deferred) - -> 04E3 live composition and false-policy readiness + -> PILOT-08 false-policy guide activation readiness -> public initial/checker-remediation intake and immutable admitted Submission cutover - -> automatically consume the durable current result + -> public trigger for the implemented selected completion outcomes | +-- contributor-correctable failure -> TASK needs_revision (no REV/CON) | -> hidden exact-cause replacement intake; setup-fault hold pending @@ -984,8 +991,9 @@ v0.1 is not ready until all of the following are true: success consequences under that manifest. B8 completion delivery is implemented; 04F2 adds exact checker remediation with zero REV/CON effects. 04F3 adds hidden exact-cause replacement intake and 04F4 hidden Operator retry. - Manager-visible setup-fault hold and live - composition remain before production shared acceptance. + Selected production completion already invokes shared acceptance for eligible + locked false-policy facts. Manager-visible setup-fault hold, PILOT-08 false-policy + guide activation and the complete public journey remain. Conditional awards remain atomic. Fulfillment obligation/root/ordinal storage and real cutoff/drain proof are required when payment fulfillment is enabled, not before the first contribution path. No fabricated observation or @@ -1028,10 +1036,19 @@ proof. The [offline gVisor build spike](engineering/pilot00-gvisor-offline-build-spike.md) selects a sealed-cache Kaniko builder plus a separate gVisor oracle sandbox for PILOT-04/PILOT-06. PILOT-04 now has a normalized external request/result -contract, immutable authorized digest-pinned registry and hidden unselected Rust -service/SDK with a typed Unix-socket adapter and request-bound ART grant. Product -runtime composition and policy routing remain absent. The spike proves only the -included small Linux fixture; +contract, immutable authorized digest-pinned registry, selected Rust service/SDK +with a typed Unix-socket adapter and request-bound ART grant, and one versioned +Workstream default checker with exactly four blocking outcomes. Both executable +catalogues are removed. Verified ZIP/default checks precede approved external PRE +images; only a complete pass permits immutable Submission creation and durable +POST dispatch. Unresolved required capabilities block guide activation with bounded, +manager-authorized requirement IDs, stages and titles; registration alone does +not clear an approved compilation's gap. Corrected compilation and approval +must bind the registered image. Production completion routes true-policy success to review_pending +and false-policy success through shared atomic acceptance. Default failures prevent +Submission creation; infrastructure failures retain recoverable outcomes. Local +Rust/MinIO integration evidence uses runc/docker-dev, not gVisor, and the contributor +upload/CLI surface remains PILOT-12 work. The spike proves only the included small Linux fixture; representative task sizing, hosted hardening and macOS fallback evidence remain. @@ -1100,23 +1117,27 @@ remaining trace sequence is: plus the REV-04C hidden FinalAcceptance/TASK/CON participant, support the additional false-path acceptance-source AUTH custody and activation prerequisites. Both branches have delivered request reservation `04E1B-A` and AUTH preparation `04E2-A`, followed by delivered authorized success outcomes `04E2-B` and hidden completion - delivery `04E1B-B8`. Production registration/live composition `04E3` remains pending; true + delivery `04E1B-B8`. PILOT-04 supplies selected production registration and Celery + composition; true routing proceeds after its own prerequisites without shared acceptance. 04F1 supplies verified failure evidence, 04F2 supplies the hidden remediation handoff, 04F3 supplies hidden exact-cause replacement intake, and 04F4 adds hidden Operator retry. Manager-visible setup-fault hold remains. The mandatory [04D1 canonical material custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04D1.md) closes the three-field ART database guarantee before authority activation. - ARCH-04C accepts the exact empty output set from the current structural - catalogue; nonempty 04B2 test slots do not claim a live evaluator capability. + ARCH-04C retains immutable output-set custody; selected external image outputs + use the same verified ART ownership rather than a structural catalogue. AUTH-OUTBOX-02 delivers shared live authority, phase audit custody and Celery delivery/recovery scans over CON-02B. Delivery termination is bounded by a - 300-second hard limit under prefork. + fixed 3,660-second prefork process hard limit. Selected POST requests have + a 3,520-second handler timeout and 3,600-second lease; completion/invalidation + retain their 240-second handler timeout and 300-second lease. Exact task and + message bounds prevent either truncating approved work or extending shutdown. ARCH-03C2 enforces a dedicated non-eager prefork delivery queue. ARCH-04E1A proves source storage and detached contracts only; its false scalar - proof does not make false activation available. Automatic `04E` delivery still needs - production registration and live composition of the implemented authorized - checker-routing handler; the installed assignment handler does not provide them. + proof does not make false activation available. PILOT-04 separately installs + selected request/completion production composition; public false-guide + activation remains PILOT-08 work. The true handoff does not require REV or fulfillment. TASK request/source-identity reservation `04E1B-A`, hidden AUTH preparation `04E2-A`, CON-07 participation and REV-04C hidden FinalAcceptance/TASK/CON @@ -1124,7 +1145,7 @@ remaining trace sequence is: closure and atomic success outcomes. B8 supplies hidden completion delivery; 04F2 extends the same operation with exact checker remediation under both policy modes. 04F3 adds hidden replacement intake and 04F4 hidden Operator retry; - manager-visible setup-fault hold and live integration `04E3` remain. A dispatcher + manager-visible setup-fault hold and the complete public intake journey remain. A dispatcher cannot authorize TASK or CHECKERS mutations. Remaining `04F` owns the authorized manager-visible setup-fault hold before public cutover. This hold retains the Submission and locked rules without contributor penalty. The remaining recovery work @@ -1152,20 +1173,20 @@ ARCH-04E1B-B3 proof: [verified ZIP metadata custody](../.commitrail/initiatives/ ARCH-04E1B-B6 proof: [atomic Submission, exact receipts and initial request custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E1BB6.md). -ARCH-04E1B-B7 proof: [hidden request delivery and invocation custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E1BB7.md). B8 supplies hidden completion routing; production registration and public intake remain unavailable. +ARCH-04E1B-B7 proof: [hidden request delivery and invocation custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E1BB7.md). B8 supplied hidden completion routing; PILOT-04 adds selected production composition, while public intake remains unavailable. -REV-12A4A proof: [scoped lifecycle control and payment-delivery deferral](../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md). ARCH-04E2-B supplies exact source receipts; B8 completion delivery, the 04F2 zero-REV/CON remediation handoff, 04F3 hidden replacement intake and 04F4 hidden Operator retry are implemented. Manager-visible setup-fault hold and production activation remain required. +REV-12A4A proof: [scoped lifecycle control and payment-delivery deferral](../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md). ARCH-04E2-B supplies exact source receipts; B8 completion delivery, the 04F2 zero-REV/CON remediation handoff, 04F3 hidden replacement intake and 04F4 hidden Operator retry are implemented. Manager-visible setup-fault hold, false-guide activation and the complete public journey remain required. -ARCH-04E2-B proof: [hidden authorized outcome custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E2B.md). B8 supplies hidden completion delivery; production registration and false-guide activation remain unavailable. +ARCH-04E2-B proof: [hidden authorized outcome custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E2B.md). B8 supplies hidden completion delivery; selected production composition is implemented, while false-guide activation remains unavailable. -ARCH-04E1B-B8 proof: [hidden completion delivery](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E1BB8.md). Both policy branches commit before acknowledgment; closed UNKNOWN invocations never automatically repeat effects. Production registration remains unavailable. +ARCH-04E1B-B8 proof: [hidden completion delivery](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E1BB8.md). Both policy branches commit before acknowledgment; closed UNKNOWN invocations never automatically repeat effects. PILOT-04 adds selected production request/completion composition; no public intake is supplied. ARCH-04F1 proof: [verified checker failure evidence](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04F1.md). The existing completion read returns exact stored completed results and distinguishes contributor/setup failures; no failure routing, replacement intake or recovery authority is activated. ARCH-04F2 delivered boundary: [authorized checker remediation handoff](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04F2.md). The existing hidden TASK/AUTH operation retains the exact current recommendation, manifest and actual decision receipt while moving TASK to `needs_revision` under either policy mode. It creates no Review, FinalAcceptance, contribution or award and adds no public API or production registration. 04F3 supplies hidden replacement intake and 04F4 hidden Operator retry; manager-visible setup-fault hold and the live drill remain. -ARCH-04F3 intended merged boundary: [exact-cause checker-remediation replacement](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04F3.md). Existing hidden ART intake and TASK creation admit a changed full ZIP only from the exact current authorized remediation source under either policy mode. The successor Submission, binding, AUTH decisions and generation-one request commit atomically; old evidence stays immutable. Uncaused `revision` runtime/replay is removed, and migration `0034_checker_remediation_replacement` refuses retained unprovable revision rows without deleting them. 04F4 adds hidden terminal-failure Operator retry; manager-visible setup-fault hold, production registration, public intake and the live drill remain. +ARCH-04F3 intended merged boundary: [exact-cause checker-remediation replacement](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04F3.md). Existing hidden ART intake and TASK creation admit a changed full ZIP only from the exact current authorized remediation source under either policy mode. The successor Submission, binding, AUTH decisions and generation-one request commit atomically; old evidence stays immutable. Uncaused `revision` runtime/replay is removed, and migration `0034_checker_remediation_replacement` refuses retained unprovable revision rows without deleting them. 04F4 adds hidden terminal-failure Operator retry; manager-visible setup-fault hold, public intake and the complete public drill remain. ARCH-04F4 intended merged boundary: [hidden Operator checker retry](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04F4.md). An active system Operator can request a reason-bound new CHECKERS generation only for the exact current terminal `infrastructure_failed` run. The existing coordinator and request handler retain a new immutable retry receipt, AUTH decision, audit and request event while preserving the failed run; fresh-authorized same-key replay validates historical ownership and the original closure without creating another run. No public route, production registration, setup-fault hold or contributor penalty is supplied by this chunk. -ARCH-04E3A proof: [hidden invocation-scoped ART materialization](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E3A.md) leases the existing Celery child provider and closes bounded scratch for hidden request delivery. Production registration remains unavailable. +ARCH-04E3A proof: [hidden invocation-scoped ART materialization](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E3A.md) leases the existing Celery child provider and closes bounded scratch for hidden request delivery. PILOT-04 adds selected production request/completion composition; no public intake is supplied. diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index b4ef29131..23b67e942 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -883,8 +883,8 @@ The preparation settings use the standard `WORKSTREAM_` environment prefix: | `WORKSTREAM_ARTIFACT_SCRATCH_MAXIMUM_CONCURRENCY` | `4` | Maximum concurrent preparations; cannot exceed the file limit. | | `WORKSTREAM_ARTIFACT_SCRATCH_MAXIMUM_WORKSPACE_ENTRIES` | `2000` | Maximum entries removable from one private processing workspace; it must be at least the accepted submission-ZIP entry limit. | | `WORKSTREAM_ARTIFACT_SCRATCH_MINIMUM_FREE_BYTES` | `536870912` | Free-space floor retained in addition to the next full reservation. | -| `WORKSTREAM_ARTIFACT_SCRATCH_RESERVATION_TTL_SECONDS` | `2400` | Database-independent wall-clock expiry for abandoned reservations. | -| `WORKSTREAM_ARTIFACT_PREPARATION_TOTAL_DEADLINE_SECONDS` | `1800` | Total first-pass and provider-consumption deadline. | +| `WORKSTREAM_ARTIFACT_SCRATCH_RESERVATION_TTL_SECONDS` | `3900` | Database-independent wall-clock expiry for abandoned reservations. | +| `WORKSTREAM_ARTIFACT_PREPARATION_TOTAL_DEADLINE_SECONDS` | `3400` | Total first-pass and provider-consumption deadline, aligned with the maximum selected POST execution budget. | | `WORKSTREAM_ARTIFACT_SCRATCH_CLEANUP_MARGIN_SECONDS` | `300` | Required margin between the total deadline and reservation TTL. | | `WORKSTREAM_ARTIFACT_SCRATCH_CLEANUP_INTERVAL_SECONDS` | `300` | Celery Beat cadence for the named stale-scratch cleanup task; accepted range is 1 through 86400 seconds. | | `WORKSTREAM_ARTIFACT_PENDING_WORK_SCAN_INTERVAL_SECONDS` | `60` | Celery Beat cadence for one authority-bound, database-cutoff pending-work page. | diff --git a/docs/spec_chunk_8_submission_artifact_policy_checkers.md b/docs/spec_chunk_8_submission_artifact_policy_checkers.md index 5c2945919..06755fd0a 100644 --- a/docs/spec_chunk_8_submission_artifact_policy_checkers.md +++ b/docs/spec_chunk_8_submission_artifact_policy_checkers.md @@ -3,12 +3,12 @@ > Historical implementation record. It does not define current sequencing or > status; later canonical specifications and merged behavior take precedence. -Current submission-bundle preparation does not revive this document's -caller-owned precheck surface or standalone registry. WS-ART-001 PLAN4 and -04B1-04B3 place these constrained policy checks in the single versioned -pre-submission catalogue after ART-owned outer-ZIP custody gates, using the -server-derived manifest and sealed workspace. Catalogue availability follows -the fail-closed mandatory/advisory semantics in ADR 0011. +Current preparation uses the four Workstream defaults followed by approved +digest-pinned external PRE images on ART-verified read-only files. Both built-in +catalogues and this document's caller-owned precheck surface are removed. +Project-specific required-file, forbidden-file and similar rules belong in the +project image. Use the [current checker framework](architecture_checker_framework.md) +for the implemented flow; the record below describes the retired implementation. ## Purpose diff --git a/experiments/pilot04_external_checker_runtime/README.md b/experiments/pilot04_external_checker_runtime/README.md index 9639c8f5b..8f94b4c24 100644 --- a/experiments/pilot04_external_checker_runtime/README.md +++ b/experiments/pilot04_external_checker_runtime/README.md @@ -12,3 +12,23 @@ fails its own result if the expected verified file differs, effective capabilities are nonzero, a Docker socket exists, the root is writable, or a network connection succeeds. This small Linux fixture does not establish hosted gVisor readiness, representative checker sizing, or product routing. + +`run_selected_post_probe.py` composes the same service with the selected POST +owner against an already migrated isolated PostgreSQL database. It registers +the supplied platform-manifest digest, builds a real locked policy, creates an +immutable Submission through the existing TASK/ART path, mounts the verified +archive through the callback-scoped POST grant, and requires the normalized +result, isolation receipt, database finalization, and workspace cleanup. The +database must be disposable and independently cleaned by the caller; the probe +does not create or drop it. + +`run_selected_end_to_end_probe.py` exercises PostgreSQL and MinIO through +selected PRE admission, immutable Submission/initial dispatch and production +request/completion handlers. It requires default-secret rejection and an actual +external PRE image rejection to preserve `in_progress` with no new Submission. +The latter uses `project-rejected.txt` as a fixture project rule, implemented +only inside the image. It also tests unavailable-service recovery through fresh +verified bytes and restarted completion delivery with exact replay. This hidden +owner drill does not provide public contributor upload/CLI, false-guide +activation or hosted gVisor deployment proof. Run it through the isolated-test +runner so owned database, role and MinIO cleanup are checked. diff --git a/experiments/pilot04_external_checker_runtime/checker.py b/experiments/pilot04_external_checker_runtime/checker.py index 86d7ba21b..b04166b6b 100644 --- a/experiments/pilot04_external_checker_runtime/checker.py +++ b/experiments/pilot04_external_checker_runtime/checker.py @@ -1,4 +1,4 @@ -"""Small protocol fixture for the local unselected external-checker probe.""" +"""Small protocol fixture for the local selected external-checker probe.""" from __future__ import annotations @@ -30,8 +30,19 @@ def main() -> int: sys.stdout.write("x" * (request["registry"]["resources"]["maximum_output_bytes"] + 1)) return 0 failures: list[str] = [] - if Path("/work/input/src/main.txt").read_bytes() != b"verified bytes": - failures.append("material") + manifest = request["input"].get("manifest") + if manifest is None: + if Path("/work/input/src/main.txt").read_bytes() != b"verified bytes": + failures.append("material") + else: + for entry in manifest: + if entry["entry_type"] != "file": + continue + value = Path("/work/input", entry["normalized_path"]).read_bytes() + digest = "sha256:" + hashlib.sha256(value).hexdigest() + if len(value) != entry["byte_count"] or digest != entry["sha256"]: + failures.append("material") + break status = Path("/proc/self/status").read_text() if "CapEff:\t0000000000000000" not in status: failures.append("capabilities") @@ -63,6 +74,13 @@ def main() -> int: } for item in failures ] + if Path("/work/input/project-rejected.txt").exists(): + findings.append({ + "code": "probe.project_rule_failed", + "level": "error", + "message": "The sample project rejects this fixture file.", + "path": "project-rejected.txt", + }) body = { "schema_version": "external_checker_result.v1", "request_digest": request["request_digest"], diff --git a/experiments/pilot04_external_checker_runtime/run_probe.py b/experiments/pilot04_external_checker_runtime/run_probe.py index e1e1eae02..d56916919 100644 --- a/experiments/pilot04_external_checker_runtime/run_probe.py +++ b/experiments/pilot04_external_checker_runtime/run_probe.py @@ -34,7 +34,7 @@ ) from app.modules.artifacts.submission_manifest import build_submission_manifest from app.modules.checkers.api.external import ( - ExternalCheckerMaterial, + PreSubmitExternalCheckerMaterial, ExternalCheckerRegistryEntry, ExternalCheckerResourceLimits, ExternalCheckerSchema, @@ -212,17 +212,6 @@ async def run(reference: str, service_binary: Path) -> dict[str, object]: created_at=datetime.now(timezone.utc), ) input_value = {"task_version": "v3"} - materials = ( - ExternalCheckerMaterial( - role="submission_archive", - content_id=uuid4(), - replica_id=uuid4(), - sha256=prepared.commitment.sha256, - byte_count=prepared.commitment.byte_count, - media_type="application/zip", - ), - ) - def request_for(mode: str): identity = PreSubmitExternalCheckerIdentity( project_id=uuid4(), @@ -234,6 +223,18 @@ def request_for(mode: str): effective_plan_sha256="sha256:" + "3" * 64, ) configuration = {"mode": mode} + materials = ( + PreSubmitExternalCheckerMaterial( + role="submission_archive", + prepared_generation_id=identity.prepared_generation_id, + attempt_id=identity.attempt_id, + attempt_request_digest=identity.attempt_request_digest, + archive_sha256=prepared.commitment.sha256, + archive_byte_count=prepared.commitment.byte_count, + semantic_manifest_sha256=manifest.sha256, + media_type="application/zip", + ), + ) return make_external_checker_execution_request( registry=registry, identity=identity, diff --git a/experiments/pilot04_external_checker_runtime/run_selected_end_to_end_probe.py b/experiments/pilot04_external_checker_runtime/run_selected_end_to_end_probe.py new file mode 100644 index 000000000..3a772c03a --- /dev/null +++ b/experiments/pilot04_external_checker_runtime/run_selected_end_to_end_probe.py @@ -0,0 +1,641 @@ +"""Run selected PRE, atomic Submission and selected POST through one Rust service.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +import subprocess +import tempfile +from io import BytesIO +from types import SimpleNamespace +from uuid import UUID +from unittest.mock import patch +from zipfile import ZipFile, ZipInfo + +from aiobotocore.config import AioConfig +from aiobotocore.session import AioSession +from sqlalchemy import func, select, text +from starlette.requests import Request + +from app.adapters.artifacts import ( + artifact_preparation_limits, + create_artifact_scratch_manager, + get_submission_bundle_preparation_command, +) +from app.adapters.checkers import external_checker_execution_factory +from app.adapters.outbox import production_outbox_delivery +from app.adapters.artifacts.internal_workers import shutdown_artifact_internal_runtime +from app.api.deps.authorization import compose_hidden_submission_creation_command +from app.core.identifiers import new_record_id +from app.modules.actors.api.service_identities import ServiceIdentity +from app.modules.artifacts.api import ( + SubmissionBundlePreparationCheckFailed, + SubmissionBundlePreparationInfrastructureUnavailable, + SubmissionBundlePreparationRequest, + SubmissionBundlePreparationStatus, +) +from app.modules.artifacts.models import ( + ArtifactVerificationJob, + SubmissionBundleAdmission, +) +from app.modules.artifacts.service import ArtifactStorageOrchestrator +from app.modules.artifacts.authorization import ( + PreparedArtifactInternalAuthority, + PreparedSubmissionBundlePreparationAuthorization, +) +from app.modules.authorization.api import ActorIdentityFacts, ActorKind +from app.modules.checkers.api.external import ( + PRE_SUBMIT_INPUT_SCHEMA_ID, + ExternalCheckerRegistrySpec, + ExternalCheckerSchema, + external_checker_json_hash, +) +from app.modules.checkers.models import CheckerRun, SelectedPreSubmitRun +from app.modules.tasks.api import SubmissionCreationRequest +from app.modules.tasks.models import Submission, SubmissionDispatch, WorkstreamTask +from experiments.pilot04_external_checker_runtime.run_probe import _image_identity +from tests.artifact_store_helpers import artifact_admission_limit_settings +from app.modules.checkers.execution_repository import stored_result +from tests.authorization.post_submit_routing.support import provision_router +from tests.tasks.evaluation_delivery.support import delivery_fixture, outcome +from tests.tasks.post_submit_routing.outcome_support import outcome_snapshot +from tests.checkers.external.support import post_registry_spec, registry_spec +from tests.archive_fixtures import byte_source +from tests.post_submit_materialization_helpers import material_fixture +from tests.tasks.lineage_fixtures import seed_started_task_for_artifact_test +from tests.test_s3_artifact_store import ( + MINIO_ACCESS_KEY, + MINIO_ENDPOINT, + MINIO_REGION, + MINIO_SECRET_KEY, + minio_settings, +) + + +def _locked_spec(phase: str, image: dict[str, str]) -> ExternalCheckerRegistrySpec: + source = registry_spec("pre_submit") if phase == "pre_submit" else post_registry_spec() + values = source.model_dump(mode="json") + if phase == "pre_submit": + document = {"type": "object"} + values["input_schema"] = ExternalCheckerSchema( + schema_id=PRE_SUBMIT_INPUT_SCHEMA_ID, + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ).model_dump(mode="json") + values["image_digest"] = image["platform_manifest_digest"] + values["resources"] |= {"deadline_ms": 30_000, "maximum_output_bytes": 8_192} + return ExternalCheckerRegistrySpec.model_validate(values) + + +async def _bucket_client(): + session = AioSession() + session.set_credentials(MINIO_ACCESS_KEY, MINIO_SECRET_KEY) + return session.create_client( + "s3", + endpoint_url=MINIO_ENDPOINT, + region_name=MINIO_REGION, + config=AioConfig(s3={"addressing_style": "path"}), + ) + + +async def _delete_bucket(bucket: str) -> None: + async with await _bucket_client() as client: + values = await client.list_objects_v2(Bucket=bucket) + objects = [{"Key": item["Key"]} for item in values.get("Contents", ())] + if objects: + await client.delete_objects(Bucket=bucket, Delete={"Objects": objects}) + await client.delete_bucket(Bucket=bucket) + + +def _secret_archive(source: bytes) -> bytes: + """Preserve the valid packet while introducing one known private key.""" + output = BytesIO() + with ZipFile(BytesIO(source)) as existing, ZipFile(output, "w") as target: + for item in existing.infolist(): + value = existing.read(item) + if item.filename == "task.toml": + value += b"\n-----BEGIN OPENSSH PRIVATE KEY-----\nencoded\n" + copy = ZipInfo(item.filename, date_time=(1980, 1, 1, 0, 0, 0)) + copy.create_system, copy.external_attr = ( + item.create_system, + item.external_attr, + ) + target.writestr(copy, value) + return output.getvalue() + + +async def _prepare(fixture, settings, request): + """Enter the actual hidden PRE composition with no owner bypass.""" + http = Request( + { + "type": "http", + "headers": [], + "app": SimpleNamespace(state=SimpleNamespace(settings=settings)), + } + ) + async with fixture.factory() as session: + authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=new_record_id(), + correlation_id=new_record_id(), + ) + return await get_submission_bundle_preparation_command( + http, + session, + authority, + PreparedSubmissionBundlePreparationAuthorization( + session, fixture.actor_context + ), + ).prepare(request) + + +async def _prove_work_failure(fixture, settings, *, external=False) -> dict[str, object]: + """Either actual default or project-image failure prevents admission.""" + task_id, assignment_id = new_record_id(), new_record_id() + params = { + "task": str(task_id), + "assignment": str(assignment_id), + "project": str(fixture.facts.project_id), + "actor": str(fixture.actor_context.actor_profile_id), + } + async with fixture.engine.begin() as connection: + await seed_started_task_for_artifact_test(connection, params) + data = _secret_archive(fixture.data) + if external: + output = BytesIO(fixture.data) + with ZipFile(output, "a") as archive: + archive.writestr("project-rejected.txt", b"sample project rejection\n") + data = output.getvalue() + request = SubmissionBundlePreparationRequest( + actor=ActorIdentityFacts( + fixture.actor_context.actor_profile_id, + fixture.actor_context.identity_link_id, + ActorKind.HUMAN, + ), + request_id=new_record_id(), + correlation_id=new_record_id(), + task_id=task_id, + assignment_id=assignment_id, + predecessor_submission_id=None, + idempotency_key=new_record_id(), + summary=fixture.creation_request.summary, + contributor_attestation=fixture.creation_request.contributor_attestation, + media_type="application/zip", + byte_source=byte_source(data), + ) + try: + await _prepare(fixture, settings, request) + except SubmissionBundlePreparationCheckFailed as error: + codes = tuple( + check.failure_code + for check in error.facts.workstream_default.checks + if check.failure_code is not None + ) + if external: + if codes or len(error.facts.external_members) != 1: + raise RuntimeError("project failure did not follow passing defaults") + result = error.facts.external_members[0].result + codes = tuple(item.code for item in result.findings) + if result.verdict != "failed" or codes != ("probe.project_rule_failed",): + raise RuntimeError("project image did not return its exact failure") + else: + raise RuntimeError("rejected input unexpectedly passed selected PRE") + async with fixture.factory() as session: + task = await session.get(WorkstreamTask, str(task_id)) + submissions = await session.scalar(select(func.count()).select_from(Submission)) + if task.status != "in_progress" or submissions != 1: + raise RuntimeError("work finding created a Submission or advanced its Task") + return { + "task_id": str(task_id), + "failure_codes": codes, + "submission_count": submissions, + } + + +def _start_service(binary: Path, config_path: Path, socket_path: Path): + """Start only the private checker service and wait for its UDS endpoint.""" + socket_path.unlink(missing_ok=True) + return subprocess.Popen( + [str(binary), str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + + +async def _wait_service(service, socket_path: Path) -> None: + for _ in range(500): + if socket_path.exists(): + return + if service.poll() is not None: + detail = service.stderr.read().strip() if service.stderr is not None else "" + raise RuntimeError( + f"checker service exited before readiness: {detail or 'no stderr'}" + ) + await asyncio.sleep(0.02) + raise RuntimeError("checker service socket did not become ready") + + +def _stop_service(service) -> None: + if service.poll() is not None: + return + service.terminate() + try: + service.wait(timeout=5) + except subprocess.TimeoutExpired: + service.kill() + service.wait(timeout=5) + + +def _request(fixture, task_id, assignment_id, data: bytes): + return SubmissionBundlePreparationRequest( + actor=ActorIdentityFacts( + fixture.actor_context.actor_profile_id, + fixture.actor_context.identity_link_id, + ActorKind.HUMAN, + ), + request_id=new_record_id(), + correlation_id=new_record_id(), + task_id=task_id, + assignment_id=assignment_id, + predecessor_submission_id=None, + idempotency_key=new_record_id(), + summary=fixture.creation_request.summary, + contributor_attestation=fixture.creation_request.contributor_attestation, + media_type="application/zip", + byte_source=byte_source(data), + ) + + +async def _verify_prepared(fixture, settings, prepared): + """Run the independent ART verifier for one freshly uploaded preparation.""" + async with fixture.factory() as session: + job_id = await session.scalar( + select(ArtifactVerificationJob.id).where( + ArtifactVerificationJob.originating_put_attempt_id + == str(prepared.put_attempt_id) + ) + ) + if job_id is None: + raise RuntimeError("fresh preparation did not enqueue verification") + await session.rollback() + verifier = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + request_id=new_record_id(), + correlation_id=new_record_id(), + ) + outcome = await ArtifactStorageOrchestrator( + session, fixture.store, fixture.namespace, settings, verifier + ).verify_object(UUID(job_id)) + if outcome != "verified": + raise RuntimeError("fresh preparation verification did not complete") + async with fixture.factory() as session: + admission = ( + await session.scalars( + select(SubmissionBundleAdmission).where( + SubmissionBundleAdmission.put_attempt_id + == str(prepared.put_attempt_id) + ) + ) + ).one() + if admission.status != "ready": + raise RuntimeError("fresh preparation admission is not ready") + return UUID(admission.id) + + +async def _prove_infrastructure_recovery( + fixture, settings, service_holder, service_binary, config_path, socket_path +): + """Retain infrastructure failure, then require a fresh successful upload.""" + task_id, assignment_id = new_record_id(), new_record_id() + params = { + "task": str(task_id), + "assignment": str(assignment_id), + "project": str(fixture.facts.project_id), + "actor": str(fixture.actor_context.actor_profile_id), + } + async with fixture.engine.begin() as connection: + await seed_started_task_for_artifact_test(connection, params) + _stop_service(service_holder[0]) + try: + await _prepare( + fixture, settings, _request(fixture, task_id, assignment_id, fixture.data) + ) + except SubmissionBundlePreparationInfrastructureUnavailable as error: + failure_code = str(error) + else: + raise RuntimeError("unavailable checker service did not fail preparation") + async with fixture.factory() as session: + task = await session.get(WorkstreamTask, str(task_id)) + before_submissions = await session.scalar( + select(func.count()).select_from(Submission) + ) + failed = ( + ( + await session.execute( + select(SelectedPreSubmitRun) + .where(SelectedPreSubmitRun.task_id == str(task_id)) + .order_by(SelectedPreSubmitRun.queued_at) + ) + ) + .scalars() + .all() + ) + if ( + task.status != "in_progress" + or before_submissions != 1 + or len(failed) != 1 + or failed[0].status != "infrastructure_failed" + ): + raise RuntimeError("infrastructure failure changed TASK or was not retained") + service = _start_service(service_binary, config_path, socket_path) + service_holder[0] = service + await _wait_service(service, socket_path) + recovered = await _prepare( + fixture, + settings, + _request(fixture, task_id, assignment_id, fixture.data), + ) + if recovered.replayed: + raise RuntimeError( + "fresh preparation did not recover infrastructure failure: " + f"status={recovered.submission_bundle_preparation_status}, " + f"admission={recovered.admission_id}, replayed={recovered.replayed}" + ) + if ( + recovered.submission_bundle_preparation_status + == SubmissionBundlePreparationStatus.STORED_PENDING_VERIFICATION + and recovered.admission_id is None + ): + admission_id = await _verify_prepared(fixture, settings, recovered) + elif ( + recovered.submission_bundle_preparation_status + == SubmissionBundlePreparationStatus.READY + and recovered.admission_id is not None + ): + admission_id = recovered.admission_id + else: + raise RuntimeError( + "fresh preparation returned an invalid durable state: " + f"status={recovered.submission_bundle_preparation_status}, " + f"admission={recovered.admission_id}" + ) + async with fixture.factory() as session: + created = await compose_hidden_submission_creation_command( + session, + fixture.actor_context, + request_id=new_record_id(), + correlation_id=new_record_id(), + ).create( + SubmissionCreationRequest( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=fixture.actor_context.actor_profile_id, + predecessor_submission_id=None, + admission_id=admission_id, + summary=fixture.creation_request.summary, + contributor_attestation=fixture.creation_request.contributor_attestation, + ) + ) + async with fixture.factory() as session: + task = await session.get(WorkstreamTask, str(task_id)) + submission_count = await session.scalar( + select(func.count()).select_from(Submission) + ) + dispatch_count = await session.scalar( + select(func.count()).select_from(SubmissionDispatch) + ) + runs = ( + ( + await session.execute( + select(SelectedPreSubmitRun.status) + .where(SelectedPreSubmitRun.task_id == str(task_id)) + .order_by(SelectedPreSubmitRun.queued_at) + ) + ) + .scalars() + .all() + ) + if ( + task.status != "evaluation_pending" + or submission_count != 2 + or dispatch_count != 2 + or tuple(runs) != ("infrastructure_failed", "completed") + ): + raise RuntimeError("fresh recovery did not commit one atomic dispatch") + return { + "failure_code": failure_code, + "retained_run_statuses": runs, + "submission_id": str(created.submission_id), + "evaluation_request_id": str(created.evaluation_request_id), + "submission_count": submission_count, + "dispatch_count": dispatch_count, + } + + +async def _production_post_delivery(fixture, settings): + """Use production handlers and a fresh worker runtime, then replay delivery.""" + # The ART arrangement is not a contributor-start authority proof. Supply + # its exact accepted-assignment prerequisite before testing routing owners. + async with fixture.factory() as session, session.begin(): + accepted = await session.execute(text( + "UPDATE public.task_assignments SET accepted_at=clock_timestamp() " + "WHERE id=:id AND accepted_at IS NULL" + ), {"id": str(fixture.request.assignment_id)}) + if accepted.rowcount != 1: + raise RuntimeError("fixture assignment acceptance was not established") + await provision_router(fixture.factory) + await delivery_fixture(fixture) + # Supply only this isolated run's configuration. Every provider, grant, + # authority, execution and routing operation remains the production owner. + with patch("app.core.config.get_settings", return_value=settings), patch( + "app.adapters.artifacts.internal_workers.get_settings", return_value=settings + ): + shutdown_artifact_internal_runtime() + try: + delivery = production_outbox_delivery(fixture.factory) + requested = await delivery.deliver( + fixture.created.evaluation_event_id, fixture.facts.project_id, + "selected-production-request", + ) + if requested is None or outcome(requested)["delivery_state"] != "acknowledged": + raise RuntimeError("production evaluation request was not acknowledged") + async with fixture.factory() as session: + run = await session.get(CheckerRun, str(fixture.created.evaluation_attempt_id)) + if run.status != "completed" or run.completion_event_id is None: + raise RuntimeError("production selected execution did not emit completion") + result = stored_result(run) + completion_id = run.completion_event_id + # Reconstruct the worker between the two durable events. No callback + # grant, lease capability or in-memory result crosses this boundary. + shutdown_artifact_internal_runtime() + delivery = production_outbox_delivery(fixture.factory) + completed = await delivery.deliver( + completion_id, fixture.facts.project_id, "selected-production-completion", + ) + if completed is None or outcome(completed)["delivery_state"] != "acknowledged": + raise RuntimeError("production completion was not acknowledged") + async with fixture.factory() as session: + before = await outcome_snapshot(session) + if await delivery.deliver( + completion_id, fixture.facts.project_id, "selected-production-replay", + ) is not None: + raise RuntimeError("production completion was delivered twice") + async with fixture.factory() as session: + if await outcome_snapshot(session) != before: + raise RuntimeError("duplicate completion changed retained owner facts") + return result + finally: + shutdown_artifact_internal_runtime() + + +async def run( + *, database_url: str, image_reference: str, service_binary: Path, root: Path +) -> dict[str, object]: + """Prove actual image execution on both sides of atomic Submission creation.""" + image = _image_identity(image_reference) + bucket = "ws-p04-e2e-" + os.urandom(8).hex() + socket_root, scratch = root / "service", root / "scratch" + socket_root.mkdir(mode=0o700) + scratch.mkdir(mode=0o700) + socket_path = socket_root / "checker.sock" + settings = minio_settings( + private_prefix="selected-flow", + ).model_copy( + update={ + **artifact_admission_limit_settings(1024 * 1024), + "artifact_s3_bucket": bucket, + "artifact_scratch_root": scratch, + "artifact_scratch_minimum_free_bytes": 0, + "external_checker_service_socket": socket_path, + "external_checker_material_root": scratch, + "external_checker_service_timeout_seconds": 90.0, + } + ) + config = { + "socket_path": str(socket_path), + "material_root": str(scratch), + "docker_binary": "/usr/bin/docker", + "runtime": "runc", + "isolation_mode": "docker-dev", + "operating_system": image["operating_system"], + "architecture": image["architecture"], + "sandbox_uid": os.getuid(), + "sandbox_gid": os.getgid(), + "cache": [image], + } + config_path = socket_root / "service.json" + config_path.write_text(json.dumps(config, separators=(",", ":"))) + scratch_owner = create_artifact_scratch_manager(settings) + scratch_owner.close() + async with await _bucket_client() as client: + await client.create_bucket(Bucket=bucket) + service = _start_service(service_binary, config_path, socket_path) + service_holder = [service] + try: + await _wait_service(service, socket_path) + adapter = external_checker_execution_factory( + socket_path=socket_path, timeout_seconds=90.0 + ).create("unix_socket") + health = await adapter.health() + async with material_fixture( + root, + database_url, + storage_settings=settings, + pre_submit_registry_spec=_locked_spec("pre_submit", image), + post_submit_registry_spec=_locked_spec("post_submit", image), + post_scratch_root=scratch, + scratch_limits=artifact_preparation_limits(settings), + ) as fixture: + result = await _production_post_delivery(fixture, settings) + async with fixture.factory() as session: + task = await session.get(WorkstreamTask, str(fixture.facts.task_id)) + submission_count = await session.scalar( + select(func.count()).select_from(Submission) + ) + run_row = await session.get(CheckerRun, str(result.attempt_id)) + if ( + submission_count != 1 + or task.status != "review_pending" + or result.outcome != "completed" + or result.execution is None + or len(result.execution.external_members) != 1 + or run_row.status != "completed" + ): + raise RuntimeError("selected flow did not retain its atomic boundaries") + member = result.execution.external_members[0] + if ( + member.result.verdict != "passed" + or member.isolation.platform_manifest_digest + != image["platform_manifest_digest"] + ): + raise RuntimeError("selected POST result or isolation differs") + workspaces = scratch / "workspaces" + workspace_cleanup = not workspaces.exists() or not any(workspaces.iterdir()) + if not workspace_cleanup: + raise RuntimeError("selected checker workspace cleanup is incomplete") + work_failure = await _prove_work_failure(fixture, settings) + external_pre_failure = await _prove_work_failure(fixture, settings, external=True) + infrastructure_recovery = await _prove_infrastructure_recovery( + fixture, + settings, + service_holder, + service_binary, + config_path, + socket_path, + ) + return { + "image": image, + "health": { + "runtime": health.runtime, + "isolation_mode": health.isolation_mode, + }, + "submission_id": str(fixture.created.submission_id), + "evaluation_request_id": str(fixture.created.evaluation_request_id), + "attempt_id": str(result.attempt_id), + "post_result_digest": result.result_digest, + "post_member_result_digest": member.result.result_digest, + "task_status": task.status, + "submission_count": submission_count, + "workspace_cleanup": workspace_cleanup, + "work_failure": work_failure, + "external_pre_failure": external_pre_failure, + "infrastructure_recovery": infrastructure_recovery, + "minio_bucket": bucket, + } + finally: + _stop_service(service_holder[0]) + await _delete_bucket(bucket) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--database-url", default=os.environ.get("WORKSTREAM_TEST_DATABASE_URL") + ) + parser.add_argument("--image-reference", required=True) + parser.add_argument("--service-binary", required=True, type=Path) + args = parser.parse_args() + if not args.database_url: + parser.error("--database-url or WORKSTREAM_TEST_DATABASE_URL is required") + with tempfile.TemporaryDirectory(prefix="ws-pilot04-selected-e2e-") as directory: + value = asyncio.run( + run( + database_url=args.database_url, + image_reference=args.image_reference, + service_binary=args.service_binary, + root=Path(directory), + ) + ) + print(json.dumps(value, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/pilot04_external_checker_runtime/run_selected_post_probe.py b/experiments/pilot04_external_checker_runtime/run_selected_post_probe.py new file mode 100644 index 000000000..1e074f957 --- /dev/null +++ b/experiments/pilot04_external_checker_runtime/run_selected_post_probe.py @@ -0,0 +1,130 @@ +"""Run the selected POST owner through real PostgreSQL, ART and Rust service custody.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +import subprocess +import tempfile + +from app.adapters.checkers import external_checker_execution_factory +from app.modules.checkers.api.external import ExternalCheckerRegistrySpec +from tests.checkers.execution.support import live_executor, reserve +from tests.checkers.external.test_selected_post_submit import _post_spec +from tests.post_submit_materialization_helpers import material_fixture + +from experiments.pilot04_external_checker_runtime.run_probe import _image_identity + + +async def run( + *, database_url: str, image_reference: str, service_binary: Path, root: Path +) -> dict[str, object]: + """Execute one registered POST image against actual Submission and ART facts.""" + image = _image_identity(image_reference) + values = _post_spec().model_dump(mode="json") + values["image_digest"] = image["platform_manifest_digest"] + values["resources"] |= {"deadline_ms": 8_000, "maximum_output_bytes": 8_192} + spec = ExternalCheckerRegistrySpec.model_validate(values) + async with material_fixture( + root, database_url, post_submit_registry_spec=spec + ) as fixture: + socket_root = root / "service" + socket_root.mkdir(mode=0o700) + socket_path = socket_root / "checker.sock" + config = { + "socket_path": str(socket_path), + "material_root": str(fixture.scratch), + "docker_binary": "/usr/bin/docker", + "runtime": "runc", + "isolation_mode": "docker-dev", + "operating_system": image["operating_system"], + "architecture": image["architecture"], + "sandbox_uid": os.getuid(), + "sandbox_gid": os.getgid(), + "cache": [image], + } + config_path = socket_root / "service.json" + config_path.write_text(json.dumps(config, separators=(",", ":"))) + service = subprocess.Popen( + [str(service_binary), str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + for _ in range(500): + if socket_path.exists(): + break + if service.poll() is not None: + raise RuntimeError("checker service exited before readiness") + await asyncio.sleep(0.02) + else: + raise RuntimeError("checker service socket did not become ready") + adapter = external_checker_execution_factory( + socket_path=socket_path, timeout_seconds=90.0 + ).create("unix_socket") + health = await adapter.health() + reservation = await reserve(fixture) + result = await live_executor( + fixture, selected_execution=adapter + ).evaluate_post_submission(fixture.request) + if result.outcome != "completed" or result.execution is None: + raise RuntimeError("selected POST execution did not complete") + if len(result.execution.external_members) != 1: + raise RuntimeError("selected POST member count differs") + member = result.execution.external_members[0] + if ( + member.result.verdict != "passed" + or member.isolation.platform_manifest_digest + != image["platform_manifest_digest"] + or result.attempt_id != reservation.attempt_id + ): + raise RuntimeError("selected POST result or isolation differs") + workspace_cleanup = list((fixture.scratch / "workspaces").iterdir()) == [] + if not workspace_cleanup: + raise RuntimeError("selected POST workspace cleanup is incomplete") + return { + "image": image, + "health": { + "runtime": health.runtime, + "isolation_mode": health.isolation_mode, + }, + "attempt_id": str(result.attempt_id), + "result_digest": result.result_digest, + "member_result_digest": member.result.result_digest, + "workspace_cleanup": workspace_cleanup, + "store_open_count": len(fixture.store.opens), + } + finally: + service.terminate() + try: + service.wait(timeout=5) + except subprocess.TimeoutExpired: + service.kill() + service.wait(timeout=5) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--database-url", required=True) + parser.add_argument("--image-reference", required=True) + parser.add_argument("--service-binary", required=True, type=Path) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="ws-pilot04-selected-post-") as directory: + result = asyncio.run( + run( + database_url=args.database_url, + image_reference=args.image_reference, + service_binary=args.service_binary, + root=Path(directory), + ) + ) + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/pilot04_external_checker_runtime/run_selected_pre_probe.py b/experiments/pilot04_external_checker_runtime/run_selected_pre_probe.py new file mode 100644 index 000000000..ea6aacf7d --- /dev/null +++ b/experiments/pilot04_external_checker_runtime/run_selected_pre_probe.py @@ -0,0 +1,268 @@ +"""Run selected PRE over genuine prepared ART bytes and the Rust service.""" + +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import replace +from datetime import UTC, datetime, timedelta +import json +import os +from pathlib import Path +import subprocess +from types import SimpleNamespace + +from app.adapters.checkers import ( + SelectedPreSubmitCheckerExecutionAdapter, + external_checker_execution_factory, +) +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.artifacts.submission_materialization import ( + PreparedBundleMaterializationService, +) +from app.modules.checkers.api.default_checker import ( + WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + WorkstreamDefaultCheckerInput, + workstream_default_input_sha256, +) +from app.modules.checkers.api.external import ( + ExternalCheckerRegistryEntry, + ExternalCheckerRegistrySpec, +) +from app.modules.checkers.api.external_policy import ( + LockedExternalCheckerBinding, + LockedExternalPreSubmitPolicy, +) +from app.modules.checkers.api.materialization import SubmissionMaterialEntry +from app.modules.checkers.api.pre_submit import SubmissionPacketView +from app.modules.checkers.api.selected_pre_submit import ( + SelectedPreSubmitLeaseFacts, + SelectedPreSubmitReservationFacts, +) +from experiments.pilot04_external_checker_runtime.run_probe import ( + _image_identity, +) +from tests.checkers.external.test_selected_pre_submit import _policy, _request + + +def _locked_policy(image: dict[str, str]) -> LockedExternalPreSubmitPolicy: + """Replace only the fixture image and resource limits, then rebind hashes.""" + fixture = _policy() + source = fixture.entries[0] + body = source.registry.model_dump( + mode="json", + exclude={ + "registry_entry_id", + "entry_digest", + "registration_operation_id", + "registered_by_actor_profile_id", + "authorization_decision_event_id", + "created_at", + }, + ) + body["image_digest"] = image["platform_manifest_digest"] + body["resources"] |= {"deadline_ms": 8_000, "maximum_output_bytes": 8_192} + spec = ExternalCheckerRegistrySpec.model_validate(body) + registry = ExternalCheckerRegistryEntry( + **spec.model_dump(), + registry_entry_id=source.registry.registry_entry_id, + entry_digest=spec.spec_digest, + registration_operation_id=source.registry.registration_operation_id, + registered_by_actor_profile_id=source.registry.registered_by_actor_profile_id, + authorization_decision_event_id=( + source.registry.authorization_decision_event_id + ), + created_at=source.registry.created_at, + ) + binding = LockedExternalCheckerBinding( + requirement_id=source.requirement_id, + stage="pre_submit", + registry=registry, + configuration=source.configuration, + configuration_sha256=source.configuration_sha256, + ) + policy_body = { + "schema_version": "external_pre_submit_policy.v1", + "workstream_default": fixture.workstream_default.model_dump(mode="json"), + "entries": [binding.model_dump(mode="json")], + } + return LockedExternalPreSubmitPolicy( + workstream_default=fixture.workstream_default, + entries=(binding,), + policy_sha256=canonical_json_hash(policy_body), + ) + + +async def run( + *, image_reference: str, service_binary: Path, root: Path +) -> dict[str, object]: + """Execute one selected PRE image with no Submission or durable ART binding.""" + image = _image_identity(image_reference) + request, inspector, manager, preparation, _ = await _request(root) + socket_root = root / "service" + socket_root.mkdir(mode=0o700) + socket_path = socket_root / "checker.sock" + config = { + "socket_path": str(socket_path), + "material_root": str(root / "scratch"), + "docker_binary": "/usr/bin/docker", + "runtime": "runc", + "isolation_mode": "docker-dev", + "operating_system": image["operating_system"], + "architecture": image["architecture"], + "sandbox_uid": os.getuid(), + "sandbox_gid": os.getgid(), + "cache": [image], + } + config_path = socket_root / "service.json" + config_path.write_text(json.dumps(config, separators=(",", ":"))) + service = subprocess.Popen( + [str(service_binary), str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + for _ in range(500): + if socket_path.exists(): + break + if service.poll() is not None: + raise RuntimeError("checker service exited before readiness") + await asyncio.sleep(0.02) + else: + raise RuntimeError("checker service socket did not become ready") + policy = _locked_policy(image) + entries = tuple( + SubmissionMaterialEntry( + item.normalized_path, + item.entry_type.value, + item.byte_count, + item.sha256, + item.executable, + ) + for item in request.manifest.entries + ) + default_input = WorkstreamDefaultCheckerInput( + archive_sha256=request.prepared_artifact.commitment.sha256, + archive_byte_count=request.prepared_artifact.commitment.byte_count, + archive_media_type="application/zip", + semantic_manifest_sha256=request.manifest.sha256, + entries=entries, + predecessor=None, + predecessor_exists=False, + current_predecessor=None, + current_rules_sha256=policy.policy_sha256, + predecessor_rules_sha256=None, + packet=SubmissionPacketView("", ""), + ) + attempt_id = new_record_id() + reservation = SelectedPreSubmitReservationFacts( + actor_profile_id=new_record_id(), + identity_link_id=new_record_id(), + project_id=request.effective_plan.lineage.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + attempt_id=attempt_id, + idempotency_key=new_record_id(), + attempt_request_digest="sha256:" + "2" * 64, + prepared_generation_id=request.prepared_artifact.generation_id, + policy_sha256=policy.policy_sha256, + default_behavior_sha256=WORKSTREAM_DEFAULT_BEHAVIOR_SHA256, + default_input_sha256=workstream_default_input_sha256(default_input), + archive_sha256=request.prepared_artifact.commitment.sha256, + archive_byte_count=request.prepared_artifact.commitment.byte_count, + semantic_manifest_sha256=request.manifest.sha256, + execution_budget_ms=120_000, + ) + request = replace( + request, + external_policy=policy, + default_checker_input=default_input, + selected_reservation=reservation, + ) + lease = SelectedPreSubmitLeaseFacts( + reservation=reservation, + lease_id=new_record_id(), + lease_generation=1, + lease_expires_at=datetime.now(UTC) + timedelta(minutes=2), + ) + adapter = external_checker_execution_factory( + socket_path=socket_path, timeout_seconds=90.0 + ).create("unix_socket") + health = await adapter.health() + materialization = PreparedBundleMaterializationService( + session=SimpleNamespace(in_transaction=lambda: False), + authorization=object(), + preparation=preparation, + checker_execution=SelectedPreSubmitCheckerExecutionAdapter( + archive_inspector=inspector, + external_execution=adapter, + ), + storage_scheme="s3", + ) + result = await materialization.materialize_selected_pre_submit( + request, + run_id=attempt_id, + lease=lease, + execution_authority=SimpleNamespace( + consume=lambda *, run_id, lease: lease + ), + ) + member = result.external_members[0] + workspaces = root / "scratch" / "workspaces" + workspace_cleanup = not workspaces.exists() or list(workspaces.iterdir()) == [] + if ( + not result.eligible + or len(result.external_members) != 1 + or member.result.verdict != "passed" + or member.isolation.platform_manifest_digest + != image["platform_manifest_digest"] + or not workspace_cleanup + ): + raise RuntimeError("selected PRE result or cleanup differs") + return { + "health": { + "runtime": health.runtime, + "isolation_mode": health.isolation_mode, + }, + "image": image, + "attempt_id": str(attempt_id), + "request_digest": reservation.request_digest, + "result_digest": result.result_digest, + "member_result_digest": member.result.result_digest, + "workspace_cleanup": workspace_cleanup, + "submission_created": False, + } + finally: + await request.prepared_artifact.close() + manager.close() + service.terminate() + try: + service.wait(timeout=5) + except subprocess.TimeoutExpired: + service.kill() + service.wait(timeout=5) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--image-reference", required=True) + parser.add_argument("--service-binary", required=True, type=Path) + args = parser.parse_args() + import tempfile + + with tempfile.TemporaryDirectory(prefix="ws-pilot04-selected-pre-") as directory: + result = asyncio.run( + run( + image_reference=args.image_reference, + service_binary=args.service_binary, + root=Path(directory), + ) + ) + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/external_checkers/fixtures/request.json b/external_checkers/fixtures/request.json index 634598184..68571145f 100644 --- a/external_checkers/fixtures/request.json +++ b/external_checkers/fixtures/request.json @@ -1 +1 @@ -{"configuration":{"threshold":1e-06},"configuration_sha256":"sha256:3257714092b3dc613247191d82d6da4c22f789929cb50696ebb38a69cc8ef1ff","identity":{"assignment_id":"66666666-6666-4666-8666-666666666666","attempt_id":"88888888-8888-4888-8888-888888888888","attempt_request_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","effective_plan_sha256":"sha256:3333333333333333333333333333333333333333333333333333333333333333","phase":"pre_submit","prepared_generation_id":"77777777-7777-4777-8777-777777777777","project_id":"44444444-4444-4444-8444-444444444444","task_id":"55555555-5555-4555-8555-555555555555"},"input":{"task_version":"v3"},"input_sha256":"sha256:7f9f19934f23f43e56d62005ba9fefb8af3a4b42df80103f475187f659ffe6cb","materials":[{"byte_count":14,"content_id":"99999999-9999-4999-8999-999999999999","media_type":"application/zip","replica_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","role":"submission_archive","sha256":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}],"registry":{"authorization_decision_event_id":"33333333-3333-4333-8333-333333333333","capability_id":"acme.safe_archive","capability_version":"v1.2.3","configuration_schema":{"document":{"additionalProperties":false,"properties":{"threshold":{"type":"number"}},"required":["threshold"],"type":"object"},"schema_id":"acme.safe_archive.configuration","schema_sha256":"sha256:fd170dacd315270c71f33ff14eb2ae68ae2a6e57b1b6ce494e08e5b63797238b","schema_version":"v1"},"created_at":"2026-10-10T06:00:00Z","entry_digest":"sha256:00603847c6b55dcae9d1f03fed5cc55d0c2f8c05ba02ad35ce1b9eb7b7e0ade2","image_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","input_schema":{"document":{"additionalProperties":false,"properties":{"task_version":{"type":"string"}},"required":["task_version"],"type":"object"},"schema_id":"external_checker_pre_submit_input","schema_sha256":"sha256:980c5b0d64a514c4d7e3c0061b99bf2281533296d9376bfdcbdefef3122e4186","schema_version":"v1"},"output_schema":{"document":{"type":"object"},"schema_id":"external_checker_result","schema_sha256":"sha256:a2c799262a3ce3c19ef5cdd983bf3d12b43ab3c426227091b909dcb7054738c0","schema_version":"v1"},"phase":"pre_submit","registered_by_actor_profile_id":"22222222-2222-4222-8222-222222222222","registration_operation_id":"11111111-1111-4111-8111-111111111111","registry_entry_id":"01890f12-3456-789a-8bcd-ef0123456789","resources":{"cpu_millis":500,"deadline_ms":30000,"maximum_output_bytes":32768,"memory_bytes":134217728}},"request_digest":"sha256:81adbc8a7cc0e13b4221ac06ecdd2f95850d6215c3a336d932ad14f8988a70b7","schema_version":"external_checker_request.v1"} +{"configuration":{"threshold":1e-06},"configuration_sha256":"sha256:3257714092b3dc613247191d82d6da4c22f789929cb50696ebb38a69cc8ef1ff","identity":{"assignment_id":"66666666-6666-4666-8666-666666666666","attempt_id":"88888888-8888-4888-8888-888888888888","attempt_request_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","effective_plan_sha256":"sha256:3333333333333333333333333333333333333333333333333333333333333333","phase":"pre_submit","prepared_generation_id":"77777777-7777-4777-8777-777777777777","project_id":"44444444-4444-4444-8444-444444444444","task_id":"55555555-5555-4555-8555-555555555555"},"input":{"task_version":"v3"},"input_sha256":"sha256:7f9f19934f23f43e56d62005ba9fefb8af3a4b42df80103f475187f659ffe6cb","materials":[{"archive_byte_count":14,"archive_sha256":"sha256:4444444444444444444444444444444444444444444444444444444444444444","attempt_id":"88888888-8888-4888-8888-888888888888","attempt_request_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","media_type":"application/zip","phase":"pre_submit","prepared_generation_id":"77777777-7777-4777-8777-777777777777","role":"submission_archive","semantic_manifest_sha256":"sha256:5555555555555555555555555555555555555555555555555555555555555555"}],"registry":{"authorization_decision_event_id":"33333333-3333-4333-8333-333333333333","capability_id":"acme.safe_archive","capability_version":"v1.2.3","configuration_schema":{"document":{"additionalProperties":false,"properties":{"threshold":{"type":"number"}},"required":["threshold"],"type":"object"},"schema_id":"acme.safe_archive.configuration","schema_sha256":"sha256:fd170dacd315270c71f33ff14eb2ae68ae2a6e57b1b6ce494e08e5b63797238b","schema_version":"v1"},"created_at":"2026-10-10T06:00:00Z","entry_digest":"sha256:00603847c6b55dcae9d1f03fed5cc55d0c2f8c05ba02ad35ce1b9eb7b7e0ade2","image_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","input_schema":{"document":{"additionalProperties":false,"properties":{"task_version":{"type":"string"}},"required":["task_version"],"type":"object"},"schema_id":"external_checker_pre_submit_input","schema_sha256":"sha256:980c5b0d64a514c4d7e3c0061b99bf2281533296d9376bfdcbdefef3122e4186","schema_version":"v1"},"output_schema":{"document":{"type":"object"},"schema_id":"external_checker_result","schema_sha256":"sha256:a2c799262a3ce3c19ef5cdd983bf3d12b43ab3c426227091b909dcb7054738c0","schema_version":"v1"},"phase":"pre_submit","registered_by_actor_profile_id":"22222222-2222-4222-8222-222222222222","registration_operation_id":"11111111-1111-4111-8111-111111111111","registry_entry_id":"01890f12-3456-789a-8bcd-ef0123456789","resources":{"cpu_millis":500,"deadline_ms":30000,"maximum_output_bytes":32768,"memory_bytes":134217728}},"request_digest":"sha256:a06707b14348ffe5ef22a55de8d4ec4845c9029da7180d5731fb2c120b5591f3","schema_version":"external_checker_request.v1"} diff --git a/external_checkers/fixtures/result.json b/external_checkers/fixtures/result.json index 71ef47034..282a0f5d5 100644 --- a/external_checkers/fixtures/result.json +++ b/external_checkers/fixtures/result.json @@ -1 +1 @@ -{"findings":[],"infrastructure_failure_code":null,"outcome":"completed","phase":"pre_submit","registry_entry_digest":"sha256:00603847c6b55dcae9d1f03fed5cc55d0c2f8c05ba02ad35ce1b9eb7b7e0ade2","registry_entry_id":"01890f12-3456-789a-8bcd-ef0123456789","request_digest":"sha256:81adbc8a7cc0e13b4221ac06ecdd2f95850d6215c3a336d932ad14f8988a70b7","result_digest":"sha256:7f62c10b491e6761a2bcd4a20ebb07cf13588c24c5b5f48ad9ad0f72d551e8b1","schema_version":"external_checker_result.v1","verdict":"passed"} +{"findings":[],"infrastructure_failure_code":null,"outcome":"completed","phase":"pre_submit","registry_entry_digest":"sha256:00603847c6b55dcae9d1f03fed5cc55d0c2f8c05ba02ad35ce1b9eb7b7e0ade2","registry_entry_id":"01890f12-3456-789a-8bcd-ef0123456789","request_digest":"sha256:a06707b14348ffe5ef22a55de8d4ec4845c9029da7180d5731fb2c120b5591f3","result_digest":"sha256:2fdede489f609ba59017fe8da4a2f7ed31307700eb7663532d9944233d7ab82d","schema_version":"external_checker_result.v1","verdict":"passed"} diff --git a/external_checkers/workstream-checker-sdk/src/lib.rs b/external_checkers/workstream-checker-sdk/src/lib.rs index 729156258..1912a644c 100644 --- a/external_checkers/workstream-checker-sdk/src/lib.rs +++ b/external_checkers/workstream-checker-sdk/src/lib.rs @@ -116,29 +116,87 @@ pub fn parse_request(bytes: &[u8]) -> Result { .get("materials") .and_then(Value::as_array) .ok_or(ContractError("request materials are invalid"))?; - if materials.len() > 32 { + if materials.is_empty() || materials.len() > 32 { return Err(ContractError("request materials are unbounded")); } let mut roles = BTreeSet::new(); for material in materials { - let material = exact_object( - material, - &[ - "role", - "content_id", - "replica_id", - "sha256", - "byte_count", - "media_type", - ], - )?; - if !roles.insert(string(material, "role")?) - || unsigned(material, "byte_count")? > i64::MAX as u64 + let material = material + .as_object() + .ok_or(ContractError("request material is invalid"))?; + if string(material, "phase")? != phase + || !roles.insert(string(material, "role")?) || !string(material, "media_type")?.contains('/') { return Err(ContractError("request material is invalid")); } - digest(material, "sha256")?; + if phase == "pre_submit" { + exact_map( + material, + &[ + "phase", + "role", + "prepared_generation_id", + "attempt_id", + "attempt_request_digest", + "archive_sha256", + "archive_byte_count", + "semantic_manifest_sha256", + "media_type", + ], + )?; + if unsigned(material, "archive_byte_count")? > i64::MAX as u64 { + return Err(ContractError("request material is invalid")); + } + digest(material, "attempt_request_digest")?; + digest(material, "archive_sha256")?; + digest(material, "semantic_manifest_sha256")?; + if string(material, "prepared_generation_id")? + != string(identity, "prepared_generation_id")? + || string(material, "attempt_id")? != string(identity, "attempt_id")? + || digest(material, "attempt_request_digest")? + != digest(identity, "attempt_request_digest")? + { + return Err(ContractError("request material identity differs")); + } + } else { + exact_map( + material, + &[ + "phase", + "role", + "submission_id", + "submission_version", + "binding_id", + "content_id", + "replica_id", + "content_sha256", + "byte_count", + "media_type", + "evidence_id", + "verification_receipt_id", + "verification_job_id", + "verification_generation", + "namespace_fingerprint", + "semantic_manifest_sha256", + ], + )?; + if unsigned(material, "byte_count")? > i64::MAX as u64 + || unsigned(material, "submission_version")? == 0 + || unsigned(material, "verification_generation")? == 0 + { + return Err(ContractError("request material is invalid")); + } + digest(material, "content_sha256")?; + digest(material, "namespace_fingerprint")?; + digest(material, "semantic_manifest_sha256")?; + if string(material, "submission_id")? != string(identity, "submission_id")? + || unsigned(material, "submission_version")? + != unsigned(identity, "submission_version")? + { + return Err(ContractError("request material identity differs")); + } + } } let resources = registry .get("resources") @@ -654,7 +712,7 @@ mod tests { .expect("Python request fixture must satisfy the Rust contract"); assert_eq!( request.request_digest, - "sha256:81adbc8a7cc0e13b4221ac06ecdd2f95850d6215c3a336d932ad14f8988a70b7" + "sha256:a06707b14348ffe5ef22a55de8d4ec4845c9029da7180d5731fb2c120b5591f3" ); let result: Value = serde_json::from_slice(include_bytes!("../../fixtures/result.json")) .expect("result fixture is JSON"); diff --git a/external_checkers/workstream-checker-service/src/main.rs b/external_checkers/workstream-checker-service/src/main.rs index 2676f1e25..5f342fa40 100644 --- a/external_checkers/workstream-checker-service/src/main.rs +++ b/external_checkers/workstream-checker-service/src/main.rs @@ -76,24 +76,57 @@ struct GrantReference { binding_digest: String, } -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone, Deserialize, Serialize)] struct GrantManifest { protocol_version: String, grant_id: String, request_digest: String, - prepared_generation_id: String, - attempt_id: String, - attempt_request_digest: String, - archive_sha256: String, - archive_byte_count: u64, - semantic_manifest_sha256: String, + #[serde(flatten)] + identity: GrantIdentity, directories: Vec, files: Vec, binding_digest: String, } -#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "phase", deny_unknown_fields)] +enum GrantIdentity { + #[serde(rename = "pre_submit")] + PreSubmit { + prepared_generation_id: String, + attempt_id: String, + attempt_request_digest: String, + archive_sha256: String, + archive_byte_count: u64, + semantic_manifest_sha256: String, + }, + #[serde(rename = "post_submit")] + PostSubmit { + submission_id: String, + submission_version: u64, + evaluation_request_id: String, + evaluation_request_digest: String, + evaluation_generation: u64, + attempt_id: String, + result_id: String, + lease_id: String, + lease_generation: u64, + lease_expires_at: String, + binding_id: String, + content_id: String, + replica_id: String, + content_sha256: String, + byte_count: u64, + evidence_id: String, + verification_receipt_id: String, + verification_job_id: String, + verification_generation: u64, + namespace_fingerprint: String, + semantic_manifest_sha256: String, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct GrantFile { normalized_path: String, @@ -572,9 +605,6 @@ fn verify_grant( } fn grant_matches_request(manifest: &GrantManifest, request: &ValidatedRequest) -> bool { - if request.phase != "pre_submit" { - return false; - } let Some(identity) = request.value.get("identity").and_then(Value::as_object) else { return false; }; @@ -583,23 +613,124 @@ fn grant_matches_request(manifest: &GrantManifest, request: &ValidatedRequest) - }; let Some(archive) = materials.iter().find_map(|item| { let object = item.as_object()?; - (object.get("role")?.as_str()? == "submission_archive").then_some(object) + (object.get("phase")?.as_str()? == request.phase + && object.get("role")?.as_str()? == "submission_archive") + .then_some(object) }) else { return false; }; let semantic_manifest_sha256 = semantic_manifest_hash(manifest).ok(); - identity - .get("prepared_generation_id") - .and_then(Value::as_str) - == Some(&manifest.prepared_generation_id) - && identity.get("attempt_id").and_then(Value::as_str) == Some(&manifest.attempt_id) - && identity - .get("attempt_request_digest") - .and_then(Value::as_str) - == Some(&manifest.attempt_request_digest) - && archive.get("sha256").and_then(Value::as_str) == Some(&manifest.archive_sha256) - && archive.get("byte_count").and_then(Value::as_u64) == Some(manifest.archive_byte_count) - && semantic_manifest_sha256.as_deref() == Some(&manifest.semantic_manifest_sha256) + match &manifest.identity { + GrantIdentity::PreSubmit { + prepared_generation_id, + attempt_id, + attempt_request_digest, + archive_sha256, + archive_byte_count, + semantic_manifest_sha256: expected_manifest, + } => { + request.phase == "pre_submit" + && identity + .get("prepared_generation_id") + .and_then(Value::as_str) + == Some(prepared_generation_id) + && identity.get("attempt_id").and_then(Value::as_str) == Some(attempt_id) + && identity + .get("attempt_request_digest") + .and_then(Value::as_str) + == Some(attempt_request_digest) + && archive + .get("prepared_generation_id") + .and_then(Value::as_str) + == Some(prepared_generation_id) + && archive.get("attempt_id").and_then(Value::as_str) == Some(attempt_id) + && archive + .get("attempt_request_digest") + .and_then(Value::as_str) + == Some(attempt_request_digest) + && archive.get("archive_sha256").and_then(Value::as_str) == Some(archive_sha256) + && archive.get("archive_byte_count").and_then(Value::as_u64) + == Some(*archive_byte_count) + && archive + .get("semantic_manifest_sha256") + .and_then(Value::as_str) + == Some(expected_manifest) + && semantic_manifest_sha256.as_deref() == Some(expected_manifest) + } + GrantIdentity::PostSubmit { + submission_id, + submission_version, + evaluation_request_id, + evaluation_request_digest, + evaluation_generation, + attempt_id, + result_id, + lease_id, + lease_generation, + lease_expires_at, + binding_id, + content_id, + replica_id, + content_sha256, + byte_count, + evidence_id, + verification_receipt_id, + verification_job_id, + verification_generation, + namespace_fingerprint, + semantic_manifest_sha256: expected_manifest, + } => { + request.phase == "post_submit" + && identity.get("submission_id").and_then(Value::as_str) == Some(submission_id) + && identity.get("submission_version").and_then(Value::as_u64) + == Some(*submission_version) + && identity + .get("evaluation_request_id") + .and_then(Value::as_str) + == Some(evaluation_request_id) + && identity + .get("evaluation_request_digest") + .and_then(Value::as_str) + == Some(evaluation_request_digest) + && identity + .get("evaluation_generation") + .and_then(Value::as_u64) + == Some(*evaluation_generation) + && identity.get("attempt_id").and_then(Value::as_str) == Some(attempt_id) + && identity.get("result_id").and_then(Value::as_str) == Some(result_id) + && identity.get("lease_id").and_then(Value::as_str) == Some(lease_id) + && identity.get("lease_generation").and_then(Value::as_u64) + == Some(*lease_generation) + && identity.get("lease_expires_at").and_then(Value::as_str) + == Some(lease_expires_at) + && archive.get("submission_id").and_then(Value::as_str) == Some(submission_id) + && archive.get("submission_version").and_then(Value::as_u64) + == Some(*submission_version) + && archive.get("binding_id").and_then(Value::as_str) == Some(binding_id) + && archive.get("content_id").and_then(Value::as_str) == Some(content_id) + && archive.get("replica_id").and_then(Value::as_str) == Some(replica_id) + && archive.get("content_sha256").and_then(Value::as_str) == Some(content_sha256) + && archive.get("byte_count").and_then(Value::as_u64) == Some(*byte_count) + && archive.get("evidence_id").and_then(Value::as_str) == Some(evidence_id) + && archive + .get("verification_receipt_id") + .and_then(Value::as_str) + == Some(verification_receipt_id) + && archive.get("verification_job_id").and_then(Value::as_str) + == Some(verification_job_id) + && archive + .get("verification_generation") + .and_then(Value::as_u64) + == Some(*verification_generation) + && archive.get("namespace_fingerprint").and_then(Value::as_str) + == Some(namespace_fingerprint) + && archive + .get("semantic_manifest_sha256") + .and_then(Value::as_str) + == Some(expected_manifest) + && semantic_manifest_sha256.as_deref() == Some(expected_manifest) + } + } } fn semantic_manifest_hash(manifest: &GrantManifest) -> Result { @@ -1605,62 +1736,176 @@ mod tests { protocol_version: GRANT_PROTOCOL.into(), grant_id: "extract_0123456789abcdef0123456789abcdef".into(), request_digest: request.request_digest.clone(), - prepared_generation_id: request.value["identity"]["prepared_generation_id"] - .as_str() - .unwrap() - .into(), - attempt_id: request.value["identity"]["attempt_id"] - .as_str() - .unwrap() - .into(), - attempt_request_digest: request.value["identity"]["attempt_request_digest"] - .as_str() - .unwrap() - .into(), - archive_sha256: request.value["materials"][0]["sha256"] - .as_str() - .unwrap() - .into(), - archive_byte_count: request.value["materials"][0]["byte_count"] - .as_u64() - .unwrap(), - semantic_manifest_sha256: String::new(), + identity: GrantIdentity::PreSubmit { + prepared_generation_id: request.value["identity"]["prepared_generation_id"] + .as_str() + .unwrap() + .into(), + attempt_id: request.value["identity"]["attempt_id"] + .as_str() + .unwrap() + .into(), + attempt_request_digest: request.value["identity"]["attempt_request_digest"] + .as_str() + .unwrap() + .into(), + archive_sha256: request.value["materials"][0]["archive_sha256"] + .as_str() + .unwrap() + .into(), + archive_byte_count: request.value["materials"][0]["archive_byte_count"] + .as_u64() + .unwrap(), + semantic_manifest_sha256: String::new(), + }, directories: Vec::new(), files: Vec::new(), binding_digest: format!("sha256:{}", "9".repeat(64)), }; - manifest.semantic_manifest_sha256 = semantic_manifest_hash(&manifest).unwrap(); + let semantic = semantic_manifest_hash(&manifest).unwrap(); + let GrantIdentity::PreSubmit { + semantic_manifest_sha256, + .. + } = &mut manifest.identity + else { + unreachable!() + }; + *semantic_manifest_sha256 = semantic; + request.value["materials"][0]["semantic_manifest_sha256"] = + Value::String(semantic_manifest_sha256.clone()); assert!(grant_matches_request(&manifest, &request)); for field in 0..6 { - let original = ( - manifest.prepared_generation_id.clone(), - manifest.attempt_id.clone(), - manifest.attempt_request_digest.clone(), - manifest.archive_sha256.clone(), - manifest.archive_byte_count, - manifest.semantic_manifest_sha256.clone(), - ); - match field { - 0 => { - manifest.prepared_generation_id = "00000000-0000-4000-8000-000000000000".into() + let original = manifest.identity.clone(); + if let GrantIdentity::PreSubmit { + prepared_generation_id, + attempt_id, + attempt_request_digest, + archive_sha256, + archive_byte_count, + semantic_manifest_sha256, + } = &mut manifest.identity + { + match field { + 0 => *prepared_generation_id = "00000000-0000-4000-8000-000000000000".into(), + 1 => *attempt_id = "00000000-0000-4000-8000-000000000000".into(), + 2 => *attempt_request_digest = format!("sha256:{}", "8".repeat(64)), + 3 => *archive_sha256 = format!("sha256:{}", "8".repeat(64)), + 4 => *archive_byte_count += 1, + _ => *semantic_manifest_sha256 = format!("sha256:{}", "8".repeat(64)), } - 1 => manifest.attempt_id = "00000000-0000-4000-8000-000000000000".into(), - 2 => manifest.attempt_request_digest = format!("sha256:{}", "8".repeat(64)), - 3 => manifest.archive_sha256 = format!("sha256:{}", "8".repeat(64)), - 4 => manifest.archive_byte_count += 1, - _ => manifest.semantic_manifest_sha256 = format!("sha256:{}", "8".repeat(64)), } assert!(!grant_matches_request(&manifest, &request)); - ( - manifest.prepared_generation_id, - manifest.attempt_id, - manifest.attempt_request_digest, - manifest.archive_sha256, - manifest.archive_byte_count, - manifest.semantic_manifest_sha256, - ) = original; + manifest.identity = original; } request.phase = "post_submit".into(); assert!(!grant_matches_request(&manifest, &request)); } + + #[test] + fn grant_lineage_matches_every_normalized_post_submit_fact() { + let digest = |byte: char| format!("sha256:{}", byte.to_string().repeat(64)); + let id = |byte: char| format!("{0}{0}{0}{0}{0}{0}{0}{0}-0000-4000-8000-000000000000", byte); + let identity = serde_json::json!({ + "phase": "post_submit", "project_id": id('1'), "task_id": id('2'), + "assignment_id": id('3'), "submission_id": id('4'), "submission_version": 2, + "evaluation_request_id": id('5'), "evaluation_request_digest": digest('5'), + "evaluation_generation": 3, "attempt_id": id('6'), "result_id": id('7'), + "lease_id": id('8'), "lease_generation": 4, + "lease_expires_at": "2026-10-11T12:00:00+00:00" + }); + let material = serde_json::json!({ + "phase": "post_submit", "role": "submission_archive", + "submission_id": identity["submission_id"], "submission_version": 2, + "binding_id": id('9'), "content_id": "aaaaaaaa-0000-4000-8000-000000000000", + "replica_id": "bbbbbbbb-0000-4000-8000-000000000000", + "content_sha256": digest('c'), "byte_count": 0, "media_type": "application/zip", + "evidence_id": "cccccccc-0000-4000-8000-000000000000", + "verification_receipt_id": "dddddddd-0000-4000-8000-000000000000", + "verification_job_id": "eeeeeeee-0000-4000-8000-000000000000", + "verification_generation": 5, "namespace_fingerprint": digest('d'), + "semantic_manifest_sha256": "" + }); + let mut manifest = GrantManifest { + protocol_version: GRANT_PROTOCOL.into(), + grant_id: "extract_0123456789abcdef0123456789abcdef".into(), + request_digest: digest('f'), + identity: GrantIdentity::PostSubmit { + submission_id: identity["submission_id"].as_str().unwrap().into(), + submission_version: 2, + evaluation_request_id: identity["evaluation_request_id"].as_str().unwrap().into(), + evaluation_request_digest: digest('5'), + evaluation_generation: 3, + attempt_id: identity["attempt_id"].as_str().unwrap().into(), + result_id: identity["result_id"].as_str().unwrap().into(), + lease_id: identity["lease_id"].as_str().unwrap().into(), + lease_generation: 4, + lease_expires_at: identity["lease_expires_at"].as_str().unwrap().into(), + binding_id: material["binding_id"].as_str().unwrap().into(), + content_id: material["content_id"].as_str().unwrap().into(), + replica_id: material["replica_id"].as_str().unwrap().into(), + content_sha256: digest('c'), + byte_count: 0, + evidence_id: material["evidence_id"].as_str().unwrap().into(), + verification_receipt_id: material["verification_receipt_id"] + .as_str() + .unwrap() + .into(), + verification_job_id: material["verification_job_id"].as_str().unwrap().into(), + verification_generation: 5, + namespace_fingerprint: digest('d'), + semantic_manifest_sha256: String::new(), + }, + directories: Vec::new(), + files: Vec::new(), + binding_digest: digest('9'), + }; + let semantic = semantic_manifest_hash(&manifest).unwrap(); + let GrantIdentity::PostSubmit { + semantic_manifest_sha256, + .. + } = &mut manifest.identity + else { + unreachable!() + }; + *semantic_manifest_sha256 = semantic.clone(); + let encoded = serde_json::to_vec(&manifest).unwrap(); + let decoded: GrantManifest = serde_json::from_slice(&encoded).unwrap(); + assert!(matches!(decoded.identity, GrantIdentity::PostSubmit { .. })); + let mut substituted = serde_json::to_value(&manifest).unwrap(); + substituted["unexpected"] = Value::Bool(true); + assert!(serde_json::from_value::(substituted).is_err()); + let mut material = material; + material["semantic_manifest_sha256"] = Value::String(semantic); + let request = ValidatedRequest { + value: serde_json::json!({"identity": identity, "materials": [material]}), + request_digest: manifest.request_digest.clone(), + registry_entry_id: id('f'), + registry_entry_digest: digest('1'), + image_digest: digest('2'), + phase: "post_submit".into(), + cpu_millis: 1, + memory_bytes: 1, + deadline_ms: 100, + maximum_output_bytes: 256, + }; + assert!(grant_matches_request(&manifest, &request)); + + let original = manifest.identity.clone(); + if let GrantIdentity::PostSubmit { + verification_generation, + .. + } = &mut manifest.identity + { + *verification_generation += 1; + } + assert!(!grant_matches_request(&manifest, &request)); + manifest.identity = original; + manifest.files.push(GrantFile { + normalized_path: "foreign.txt".into(), + byte_count: 0, + sha256: digest('a'), + executable: false, + }); + assert!(!grant_matches_request(&manifest, &request)); + } } diff --git a/mcp_server/contracts/actor_get.json b/mcp_server/contracts/actor_get.json index 9352adef0..50533da2e 100644 --- a/mcp_server/contracts/actor_get.json +++ b/mcp_server/contracts/actor_get.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "083310034d72651d6936c869f0888d8c871330d3d8e3d2f3b7cb88f40edb0a76", + "canonical_sha256": "7c526951dbaa08ff77173242ded4bc94b51376dca6bf7dd447d29764f08188e0", "components": { "schemas": { "ActorProfileAdminResponse": { @@ -223,6 +223,7 @@ "workstream.artifact.checker_output", "workstream.task.assignment_reconciler", "workstream.task.post_submit_router", + "workstream.checker.pre_submit", "workstream.checker.post_submit", "workstream.outbox.dispatcher", "workstream.project.setup", @@ -504,7 +505,7 @@ "schema_version": 1, "source": { "action_id": "actor.profile.read", - "backend_git_sha": "2134c7be05d10c381649872d423e957f9c18caff", + "backend_git_sha": "3fde44797607d4c187dc62f93876e6ae70d5c555", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/actors/{actor_profile_id}" diff --git a/mcp_server/contracts/admin_roles_list.json b/mcp_server/contracts/admin_roles_list.json index 83757d472..d2a684389 100644 --- a/mcp_server/contracts/admin_roles_list.json +++ b/mcp_server/contracts/admin_roles_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "a59d6648dfb3bf4f4acc3f04c153e26cdfb950d3d1a0075a177611d3e2d9a4ef", + "canonical_sha256": "c6bc364e9aa3565a4bd7d026f2fa6625aed883e6c302118d18be8ace871e8546", "components": { "schemas": { "AdminRole": { @@ -150,6 +150,8 @@ "actor.identity_link.reactivate", "task.assignment.authority_reconcile", "task.post_submit.route", + "checker.pre_submit.execute", + "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", "outbox.dispatch", @@ -426,7 +428,7 @@ "schema_version": 1, "source": { "action_id": "authorization.admin_role_definitions.read", - "backend_git_sha": "2134c7be05d10c381649872d423e957f9c18caff", + "backend_git_sha": "3fde44797607d4c187dc62f93876e6ae70d5c555", "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 72c26ce8f..82a1a5ab6 100644 --- a/mcp_server/contracts/authorization_context_get.json +++ b/mcp_server/contracts/authorization_context_get.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "d49f96eb6f6b2731b8b18ca023676402dfe7b0236e1615586839c3326b109316", + "canonical_sha256": "bb2a74e3a63afd3ec11b3a971d1d4b607acbb44a03b78946046c0c9c8cdacfe0", "components": { "schemas": { "ActionId": { @@ -50,6 +50,8 @@ "actor.identity_link.reactivate", "task.assignment.authority_reconcile", "task.post_submit.route", + "checker.pre_submit.execute", + "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", "outbox.dispatch", @@ -541,7 +543,7 @@ "schema_version": 1, "source": { "action_id": "actor.authorization_context.read", - "backend_git_sha": "e8d5983e8fcd83980c709a4a2d7c3469a936e11b", + "backend_git_sha": "3fde44797607d4c187dc62f93876e6ae70d5c555", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/actors/me/authorization-context" diff --git a/mcp_server/contracts/permissions_list.json b/mcp_server/contracts/permissions_list.json index d14c4c10b..6e37720a8 100644 --- a/mcp_server/contracts/permissions_list.json +++ b/mcp_server/contracts/permissions_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "610114e15ba7a4204cd3df81ea15455e1060e82d49d70ff491e344b518c61be3", + "canonical_sha256": "77afba28dc3d6793ab29cd4b1514dd82cdfb90c65f2e007df9411700d7247b47", "components": { "schemas": { "ApiError": { @@ -87,7 +87,7 @@ "type": "array" }, "total": { - "const": 78, + "const": 80, "title": "Total", "type": "integer" } @@ -113,6 +113,8 @@ "actor.identity_link.reactivate", "task.assignment.authority_reconcile", "task.post_submit.route", + "checker.pre_submit.execute", + "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", "outbox.dispatch", @@ -389,7 +391,7 @@ "schema_version": 1, "source": { "action_id": "authorization.permission_catalogue.read", - "backend_git_sha": "2134c7be05d10c381649872d423e957f9c18caff", + "backend_git_sha": "3fde44797607d4c187dc62f93876e6ae70d5c555", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/authorization/permissions" diff --git a/mcp_server/tests/test_access_reads.py b/mcp_server/tests/test_access_reads.py index 0f4873cfa..deae42527 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": 78} + return {"items": [{"permission_id": "actor.profile.read_any"}], "total": 80} if name == "workstream_admin_roles_list": return { "items": [ diff --git a/scripts/test_lightweight_agent_gates.py b/scripts/test_lightweight_agent_gates.py index afb34d4e8..96dfc7140 100644 --- a/scripts/test_lightweight_agent_gates.py +++ b/scripts/test_lightweight_agent_gates.py @@ -57,13 +57,13 @@ def test_cli_full_contract_preserves_failure_and_timeout_exits(self) -> None: ) self.assertEqual(result.returncode, exit_code, result.stderr) - def test_backend_redis_services_use_the_exact_official_mirror_image(self) -> None: + def test_backend_redis_services_use_the_exact_official_image(self) -> None: workflow = Path(".github/workflows/backend.yml").read_text(encoding="utf-8") images = re.findall(r"(?m)^ redis:\n image: (\S+)$", workflow) self.assertEqual( images, [ - "public.ecr.aws/docker/library/redis:7-alpine@sha256:" + "docker.io/library/redis:7-alpine@sha256:" "520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7" ] * 2,