diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index 5d6523f69..5e5745231 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -26,11 +26,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "e040cdd6cc39c178be02760f9083cc0a3f8c89c69ea425e39744a4ae36e8a56b", - "end_line": 1246, + "content_sha256": "533fe94de5901976c7bbce4684ee403eef8afb1c36a4dfaf7b384fdb2eabe172", + "end_line": 1245, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1246, + "observed_lines": 1245, "path": "backend/app/modules/authorization/runtime.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -63,14 +63,14 @@ { "capability": "oversized_authorization_structure", "content_sha256": "81b4eb9fbfc6b45699d95329d20429d2d47910fa0255a4d50cd21cc80521845e", - "end_line": 904, + "end_line": 911, "hard_limit": 100, "kind": "production_function", "observed_lines": 166, "path": "backend/app/modules/authorization/prepared.py", "qualified_symbol": "PreparedAuthorizationService._binding", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 739 + "start_line": 746 }, { "capability": "oversized_authorization_structure", @@ -134,11 +134,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "3143207c7619fcbffcc057c527af252be77be5fd36453224b9e1dea2a1fafcbf", - "end_line": 11066, + "content_sha256": "c86a99e75b16d1235acbe6d86e24cf7bff524e472e660cd7c88d37b8c5a35161", + "end_line": 11059, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 11066, + "observed_lines": 11059, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -291,14 +291,14 @@ { "capability": "oversized_authorization_structure", "content_sha256": "10003810b90ad8ea86434aaae1a38e650c5df4bebd09bb0577ac3ebb7a522351", - "end_line": 2954, + "end_line": 2957, "hard_limit": 120, "kind": "test_function", "observed_lines": 121, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_route_preserves_outcome_transaction_contract", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2834 + "start_line": 2837 }, { "capability": "oversized_authorization_structure", @@ -314,39 +314,39 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "c074ee206b423f15c088d2c7fe7577bf9e4aa094fcc81a126a4abea671af8d2a", - "end_line": 6069, + "content_sha256": "0858a7b107e68244cd719c9521856944411f88442818ae491ebb58a45ef4f417", + "end_line": 6070, "hard_limit": 120, "kind": "test_function", - "observed_lines": 128, + "observed_lines": 126, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_actor_authority_crossed_mutations_complete_in_both_orders", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5942 + "start_line": 5945 }, { "capability": "oversized_authorization_structure", - "content_sha256": "eee28eb08aca0b6e4ba7c0cbf08280b58dc2ff86a822ccba3e33a665b6a285f8", + "content_sha256": "56e6f85cc1b712b6410bee986d54e59e4dbad4c3f73c0599f12fe284587577a7", "end_line": 6387, "hard_limit": 120, "kind": "test_function", - "observed_lines": 313, + "observed_lines": 312, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6075 + "start_line": 6076 }, { "capability": "oversized_authorization_structure", "content_sha256": "5cbc72df2c822f2f57db164c0ea4aa2f2d76d591313f69d6496fc26b127b581e", - "end_line": 4052, + "end_line": 4055, "hard_limit": 120, "kind": "test_function", "observed_lines": 149, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_11c2_reads_require_exact_admin_context_and_role_allowlist", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3904 + "start_line": 3907 }, { "capability": "oversized_authorization_structure", @@ -363,26 +363,26 @@ { "capability": "oversized_authorization_structure", "content_sha256": "3d2a9843510cdb61b34500aa79a8ed3572df72723ab401f0245300e10c70044e", - "end_line": 9343, + "end_line": 9336, "hard_limit": 120, "kind": "test_function", "observed_lines": 174, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_read_permissions_have_postgresql_role_scope_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9170 + "start_line": 9163 }, { "capability": "oversized_authorization_structure", "content_sha256": "0da4e29b29ebd44bd9049145ee4148531320bce53859ad5341e5f57261e5a884", - "end_line": 10779, + "end_line": 10772, "hard_limit": 120, "kind": "test_function", "observed_lines": 227, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_and_all_operation_mappings_commit_one_linked_pair", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10553 + "start_line": 10546 }, { "capability": "oversized_authorization_structure", @@ -411,14 +411,14 @@ { "capability": "oversized_authorization_structure", "content_sha256": "217ea21f4a2cbd31a4428ba1f01a9fcd90e73ee95fedb2587100a847e8921926", - "end_line": 10308, + "end_line": 10301, "hard_limit": 120, "kind": "test_function", "observed_lines": 154, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_service_actor_replay_fails_closed_on_committed_state_drift", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10155 + "start_line": 10148 }, { "capability": "oversized_authorization_structure", @@ -471,14 +471,14 @@ { "capability": "oversized_authorization_structure", "content_sha256": "32083a125798736fa17533aaa767c0236ae3042de4f7d8debdce5f15e0a801fe", - "end_line": 9625, + "end_line": 9618, "hard_limit": 100, "kind": "test_helper", "observed_lines": 130, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9496 + "start_line": 9489 } ], "exceptions": [], diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 3064866b2..a2d42ed4d 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -536,6 +536,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/post_policy.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/post_submit.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/prepared_adapter_bindings.py" @@ -640,6 +644,10 @@ "group": "auth", "target": "backend/app/modules/authorization/post_policy_authorization.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/post_submit_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/pre_submit_materialization.py" @@ -648,6 +656,10 @@ "group": "auth", "target": "backend/app/modules/authorization/prepared.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/prepared_post_submit_replay.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/prepared_projection_replay.py" @@ -780,10 +792,6 @@ "group": "artifacts", "target": "backend/app/modules/checkers/execution.py" }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/execution_authority.py" - }, { "group": "artifacts", "target": "backend/app/modules/checkers/execution_coordination.py" @@ -1513,7 +1521,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "62d3e0889b562d645958338bd0b5cafefadbefaf288d77163076114d768b96d2", + "authority_digest": "941150a6b283032aefd8f41e1be6d5aaf1113d90349e943a0d2c8d9efe177fb2", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index d1cbe6f9f..610b1e226 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -8,12 +8,12 @@ for current product capability. |---|---|---| | [WS-DB-002](initiatives/WS-DB-002/OVERVIEW.md) | Complete | Shared UUIDv7 record generation, native-UUID relationships and fresh v0.1 baseline; natural-owner retry custody and aligned CI/local setup | | [WS-MCP-002](initiatives/WS-MCP-002/OVERVIEW.md) | Planned | Three self-service tools delivered through WS-MCP-002-02; 24 tools remain and WS-MCP-002-03 administrative reads are next | -| [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Public guide activation and task reads, hidden approved-guide intake, exact post-submit input materialization and hidden ARCH-04B2 checker-output custody delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 live authority is next | -| [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Hidden exact post-submit input materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden durable execution delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 live authority next | -| [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Manager guide activation, public task authority, dispatcher mechanics and hidden intake are delivered; ARCH-04B input and ARCH-04B2 output custody exist with deny-only/unavailable production authority; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 live authority is next | -| [WS-CON-001](initiatives/WS-CON-001/OVERVIEW.md) | Planned | ContributionPolicy administration and guide binding plus hidden intake, post-submit input and ARCH-04B2 output custody delivered; ARCH-04C hidden durable execution delivered; ARCH-04D2 authority precedes shared FinalAcceptance, submitter ContributionRecord and applicable awards | -| [WS-AUTH-003](initiatives/WS-AUTH-003/OVERVIEW.md) | Planned | TASK/checker canonical history authority delivered and alternate gate removed; AUTH-18 public manager activation and ARCH-03D hidden intake delivered; ARCH-04B hidden input and ARCH-04B2 output custody delivered; continue boundary recovery with ARCH-04C hidden durable execution delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 live authority next | -| [WS-POL-003](initiatives/WS-POL-003/OVERVIEW.md) | Planned | Unified setup, public manager policy operations, phase composition, public guide activation, hidden intake, post-submit input and ARCH-04B2 output custody delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 live authority is next | +| [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Public guide activation and task reads, hidden approved-guide intake, exact post-submit input materialization and hidden ARCH-04B2 checker-output custody delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next | +| [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Hidden exact post-submit input materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden durable execution delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 exact input/execution/finalization authority delivered; ARCH-04E1A routing-source facts next | +| [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Manager guide activation, public task authority, dispatcher mechanics and hidden intake are delivered; ARCH-04B input and ARCH-04B2 output custody exist; only output-file authority remains unavailable; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next | +| [WS-CON-001](initiatives/WS-CON-001/OVERVIEW.md) | Planned | ContributionPolicy administration and guide binding plus hidden intake, post-submit input and ARCH-04B2 output custody delivered; ARCH-04C hidden durable execution delivered; ARCH-04D2 input/execution/finalization authority delivered; ARCH-04E1A precedes shared FinalAcceptance, submitter ContributionRecord and applicable awards | +| [WS-AUTH-003](initiatives/WS-AUTH-003/OVERVIEW.md) | Planned | TASK/checker canonical history authority delivered and alternate gate removed; AUTH-18 public manager activation and ARCH-03D hidden intake delivered; ARCH-04B hidden input and ARCH-04B2 output custody delivered; continue boundary recovery with ARCH-04C hidden durable execution delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 exact input/execution/finalization authority delivered; ARCH-04E1A routing-source facts next | +| [WS-POL-003](initiatives/WS-POL-003/OVERVIEW.md) | Planned | Unified setup, public manager policy operations, phase composition, public guide activation, hidden intake, post-submit input and ARCH-04B2 output custody delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next | | [WS-REV-001](initiatives/WS-REV-001/OVERVIEW.md) | Planned | Shared acceptance/source and existing fence foundations; human hidden review work remains independently dependency-gated | | [WS-QUAL-002](initiatives/WS-QUAL-002/OVERVIEW.md) | Planned | Populate subsystem ownership before changed-line mutation work | | [WS-QUAL-003](initiatives/WS-QUAL-003/OVERVIEW.md) | Planned | Audit and prune test proof, add missing safety cases, decompose oversized test modules | diff --git a/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md b/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md index 83fbaf02d..582385aa9 100644 --- a/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md @@ -12,10 +12,12 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), [ARCH-04A consolidation](WS-ARCH-001-04A.md) canonical post-submit contracts/conformance. - Intent: keep product modules behind explicit ports and composition roots. - Current boundary: one CHECKERS catalogue, compiler/parser and implementation - per checker ID serve active policy consumers; production post-submit phase - execution remains unavailable. -- Delivered storage boundary: hidden [ARCH-04B2 checker-output custody](WS-ARCH-001-04B2.md), following hidden input materialization (ARCH-04B). Typed store, byte-free recovery and flush-only verified binding exist; the CHECKERS zero-slot reservation reader is implemented; production authority remains deny-only. -- Next usable boundary: ARCH-04D2 exact service authority after delivered ARCH-04D1 canonical material custody and [ARCH-04C durable execution](WS-ARCH-001-04C.md), including the structural catalogue's empty output set. [ARCH-04D1](WS-ARCH-001-04D1.md) supplies the canonical ART database prerequisite; service activation remains separate. + per checker ID serve active policy consumers; hidden post-submit execution now + has exact fixed-service authority. Automatic dispatch/routing remains unavailable. +- Delivered storage boundary: hidden [ARCH-04B2 checker-output custody](WS-ARCH-001-04B2.md), following hidden input materialization (ARCH-04B). Typed store, byte-free recovery and flush-only verified binding exist; the CHECKERS zero-slot reservation reader is implemented; output write/bind authority remains unavailable. +- Next usable boundary: ARCH-04E1A routing-source facts after delivered + [ARCH-04D2](WS-ARCH-001-04D2.md) exact input, execute and finalize authority. + Output-file authority remains unavailable for the zero-output catalogue. [AUTH-18](../WS-AUTH-001/WS-AUTH-001-18.md) delivers public manager activation context and exact guide activation using the existing CP07 operation. [CP05A](WS-ARCH-001-CP05A.md) supplies public Finance policy administration and recoverable draft selectors. @@ -34,8 +36,8 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), completed [ARCH-03B8](WS-ARCH-001-03B8.md) hidden task audit evidence and [ARCH-03B7](WS-ARCH-001-03B7.md) requirements projections and [ARCH-03B6](WS-ARCH-001-03B6.md) locked-context projections and [ARCH-03B5](WS-ARCH-001-03B5.md) current contributor/manager work context and ARCH-03B4 hidden contributor/management task detail, ARCH-03B3 hidden management/operational queues and ARCH-03B2 contributor-ready queue facts, ARCH-03B1 detached metadata and CP08 lineage and minimal writers and ARCH-03A internal guide context, following delivered CP07 activation and AUTH-12H live manager authority. POL-04B unified setup, POL-05/06 manager operations and POL-07B internal phase composition are delivered. - The production post-submit phase remains unavailable until its separately - sequenced ART/CHECKER/AUTH execution boundaries land. + Exact hidden post-submit execution authority is delivered; automatic dispatch, + routing and public intake remain subsequent boundaries. - Governing sources: `docs/architecture_lockdown.md`, accepted ADRs, code, and architecture tests. - Preserve: no concrete-adapter imports in product services and no duplicate diff --git a/.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04D2.md b/.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04D2.md new file mode 100644 index 000000000..8c0f83e4d --- /dev/null +++ b/.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04D2.md @@ -0,0 +1,344 @@ +# ARCH-04D2 — Exact post-submit service authority + +- Initiative: `WS-ARCH-001` +- Durable disposition: `Complete` +- Risk: L1 (fixed-service authorization, retained evidence and concurrent execution). +- Intended merge outcome: existing post-submit materialization, execution and finalization use live AUTH/PREP with exact current execution custody; automatic dispatch, routing, recovery and public intake remain subsequent boundaries. + +## Intent + +Continue the contribution milestone: claim -> ZIP upload -> intake feedback or +immutable Submission -> automatic post-submit evaluation -> governed outcome. +The input baseline includes 04D1's canonical ART material validator through migration +`0009_checker_material_lineage`. CHECKERS already owns reservations, worker +leases, execution, retained results and completion events. Before this change, production composition +still denies materialization, execution and finalization. + +The current catalogue produces no output files. Output reservation slots and +finalization output bindings are empty by contract. Therefore activate exactly +three current actions, not the older skeleton's five. Keep output ingestion and +binding unavailable until a real registered producer and its atomic binding +proof exist. Controlled output fixtures do not justify live authority. + +Terminal replay currently calls ordinary consume and discards its new receipt. +Live AUTH would duplicate audit evidence. Replace that behavior with exact stored +receipt validation through the existing PREP replay machinery. No second replay +protocol or compatibility consume path is permitted. + +## Design + +Register `workstream.checker.post_submit` with only +`checker.post_submit.execute` and `checker.post_submit.finalize`, each with its +same-named permission. Activate the existing +`artifact.post_submit.checker_input.materialize` action only for +`workstream.artifact.materializer`. Keep human roles, dispatcher, output writers, +review services and artifact binding services unable to execute/finalize checks. +Use existing explicit service provisioning; absent, revoked or inactive actors +deny. Do not create principals automatically. + +Extend CHECKERS' existing nominal execution/finalization contracts with stored +receipt replay methods. One canonical CHECKERS authority-digest helper commits a bounded envelope: +domain/phase, action/permission/service, checker-run resource and project scope, +project/task/submission/version, immutable request ID/digest/generation, +attempt/result IDs and worker lease ID/generation/UTC expiry. Finalization adds +the stored execute receipt, accepted result digest/outcome/failure code, exact +material object or null and empty output tuple. The guarded request digest +already commits the complete locked request, policy and catalogue; do not copy +private packet text into audit evidence. AUTH's +phase-specific resource context binds these facts to the exact action and project; +its audit contains bounded identities/digests, not guide text, evaluator text or +provider coordinates. Finalization also binds the stored execute receipt. +Execution preparation binds the immutable request before owner locks; the +owner creates or recovers its lease under locks before final consumption. +Finalization preparation binds the immutable request before owner locks; +consumption extends the final resource with the execute receipt read from the +locked run. Caller input cannot select or replace that stored receipt. + +Change the CHECKERS-owned materialization port to accept `ExecuteFacts` rather +than an unleased request. Add a typed `CurrentExecutionPort` at CHECKERS' public +API, implemented using `ExecutionRepository.require_lease` and injected into ART +through composition. ART must not query CHECKERS persistence privately. ART maps +its resolved selection into exact bounded materialization facts (admission, +evidence, binding/content/replica, receipt/job/generation, namespace commitment, +semantic manifest and execution facts); storage coordinates stay inside ART. +A nominal context-manager `prepare_materialization(ExecuteFacts)` keeps AUTH +preparation alive in the caller transaction while currentness and selection are +checked. Pre-I/O consumption returns the materialization receipt; post-I/O +preparation validates that same receipt under fresh authority and exact facts, +without inserting another allow. The receipt is a bounded ID, not a transferable +PREP handle. +The materialization authority seam remains a typed consumer contract, implemented +by AUTH, without exposing an opaque PREP handle or importing ART selection models +into AUTH. + +Each short materialization transaction follows AUTH fixed-service locks -> +CHECKERS fence -> current run -> TASK/ART selection -> AUTH audit consumption. +Repeat fresh currentness, selection and stored-receipt authority validation after I/O. Compare +the complete selected identity across phases. Close all sessions, transactions +and PREP handles before provider, scratch or evaluator work. Known foreign, +substituted, expired or superseded executions deny before byte access. A takeover +or revocation after this check cannot undo a prior authorized read; fresh +post-I/O checks and finalization prevent publication. No lease pinning or locks +across I/O are introduced. + +Execution/finalization follow AUTH fixed-service locks -> CHECKERS fence -> run +-> AUTH evidence -> member rows/completion event/terminal facts. Finalization +uses fresh authority after I/O and verifies exact current lease. The final +receipt, result members, terminal material/outcome and completion event commit +or roll back together. Do not add ART feature locks to finalization. + +Exact terminal replay rechecks live authority and validates the stored +phase-specific audit receipt, returning existing result identities without new +audit rows, storage access or product effects. Expired historical leases may be +read for exact terminal replay; unfinished execution still requires a live lease. +A replaced principal cannot borrow an old principal's receipt. On terminal +`evaluate_post_submission` replay, `_claim` validates the stored execute receipt +and returns the existing `FinalizeFacts` rebuilt from the locked terminal run. +After that transaction closes, orchestration calls the existing `finalize` method +with those retained facts. A second fresh AUTH-first transaction validates the +current fence, unchanged terminal facts and stored finalize receipt before +returning the result. This reuses the existing finalization operation, calls no +provider and validates both receipts; revocation or a successor between the two +transactions denies rather than returning stale authority. + +Migration `0010_post_submit_authority` follows 0009. Update the closed actor +identity and authorization evidence constraints required by the three actions, +keeping ORM/catalogue/database parity. Require exact retained receipt provenance, +not merely a non-null UUID or an audit-row foreign key. Reuse the existing outbox +pattern: a schema-qualified `checker_post_submit_authority_digest(checker_runs, +phase)` reconstructs the same bounded envelope from guarded run columns, +request/result digests and material. Use the existing canonical-JSON and UTC SQL +helpers; add no parallel JSON framework or caller-writable authority-digest column. + +Deferrable evidence foreign keys and a semantic constraint trigger validate each +non-null execute/finalize receipt against the immutable authority event: allow +kind/domain, exact action/permission, fixed checker service principal, no denial +or grant, exact project/attempt resource, and recomputed context digest. Finalize +must chain a non-null stored execute receipt and use a distinct event. Existing run +custody alone permits replacement only with the valid next lease; terminal +receipt identities stay immutable. Attaching a new receipt requires the active +fixed actor and identity link; unchanged historical references and migration +preflight validate identity without requiring current activity. Historical proof does not depend on the +principal remaining active after its authorized operation. + +Lock against writers across preflight and installation. Refuse unprovable +retained receipt references without deleting rows, rewriting IDs or inventing +allow events. Preserve provable rows and queued reservations. Schema-qualify +protected tables/functions and pin safe search paths. Evolve the installed schema +with 0010; do not rewrite the frozen 0001 baseline snapshot. Update head and +schema fingerprint proof. + +Current-head running/terminal fixtures must obtain real typed AUTH receipts +through the existing service/PREP owner; remove random-UUID authority participants +from those paths. A narrowly scoped predecessor-migration fixture may represent +that predecessor's persisted shape to prove preservation/refusal, without +claiming live authorization. Pin earlier migration-specific tests to their actual +revision rather than implicitly upgrading through unrelated later migrations. +This affected-consumer conversion is part of this one authority change; it adds +no production compatibility path or repository-wide cleanup prerequisite. + +## Bounded change + +### Allowed files + +- `backend/app/modules/actors/api/service_identities.py` and + `backend/app/modules/actors/models.py` for the one fixed identity. +- `backend/app/modules/authorization/catalogue.py`, `admin_schemas.py`, `runtime.py`, `prepared.py`, + `kernel.py`, `domain/prepared_service.py`, `domain/resource_digest.py`, + `domain/action_groups.py`, `domain/audit.py`, `domain/audit_targets.py`, and existing prepared replay + helpers; cohesive new `domain/post_submit.py`, `post_submit_authorization.py` + and `prepared_post_submit_replay.py` for the exact three-action integration. +- `backend/app/modules/checkers/api/execution.py`, `api/materialization.py`, + `execution.py`, `execution_authority.py`, `execution_coordination.py`, + `execution_repository.py`, `models.py`, and `backend/app/adapters/checkers/__init__.py`. +- `backend/app/modules/audit/schemas.py` for the closed checker-run resource + vocabulary and safe exact audit facts. +- `backend/app/adapters/auth/__init__.py` for AUTH-owned concrete composition + consumed by the CHECKERS/ART composition roots. +- `backend/app/modules/artifacts/post_submit_materialization.py`, + `post_submit_selection.py`, `sources.py`, `preparation.py` and + `backend/app/adapters/artifacts/__init__.py`. The existing scratch-close and + processing owners must preserve cleanup errors during cancellation. +- `backend/alembic/versions/0010_post_submit_authority.py`, + `backend/alembic/env.py` and existing schema/head verification fixtures. +- New focused `backend/tests/authorization/post_submit/` tests and helpers; + existing CHECKERS execution/currentness, materialization and service catalogue, + provisioning, PREP, schema and audit-contract tests whose expectations change; + `backend/tests/test_artifact_preparation.py` retains retryable scratch ownership + proof while replacing obsolete cancellation-masks-cleanup expectations. + Shared `backend/tests/post_submit_materialization_helpers.py`, + `backend/tests/tasks/submission_lineage_support.py`, + `backend/tests/submission_fixtures.py`, CHECKERS storage/material/migration + helpers, and their retained-history/task/review-queue consumers for real + authority receipt custody and required leased-materialization calls. Preserve + every distinct isolation, privacy, lineage and historical-material assertion. +- `mcp_server/contracts/authorization_context_get.json` for the existing MCP + consumer's exact action-enum snapshot; no MCP tool or permission expansion. +- Existing behavior-ownership, module-boundary, lane catalogue and test-structure + inventories/tests only to register real changed owners and proof; no gate + weakening or unrelated CI refactoring. +- This record and adopted 04D contract; current ARCH/AUTH/ART/POL/CON/XINT + navigation, Commitrail index, README, checker/artifact/authorization/data-model + specifications, architecture/operating documents, authorization custody page, + current system data-flow HTML and roadmap for affected capability claims. + Include the architecture brief source diagrams and their rendered SVG/PNG/PDF + exports so current diagrams agree with the same authority boundary. + Local roadmap exports only if present. + +### Prohibited changes + +No public routes, automatic dispatcher handlers, routing or TASK/REV/CON +transitions, final acceptance, reviewer fabrication, compensation changes, +new checker capabilities or output-file authority, operator retries, arbitrary +service identities, generic artifact reads, external adapter changes, retained +data deletion, compatibility aliases or parallel implementation. Do not weaken +module boundaries, test selection, timeouts or completeness checks. The separate +CI impact-reporting and service-provisioning-test PRs are not this scope. + +## Acceptance criteria + +Use existing real PostgreSQL `material_fixture` canonical Submission/ART lineage, +existing fixed-service provisioning and Local/MinIO stores. The tests below +must reach the named assertion with real guards enabled, not permissive AUTH +participants or a fictitious output producer. The live fixture explicitly +provisions checker/materializer principals and composes the production +AUTH/CHECKERS/ART adapters. The former controlled materialization authority is removed. + +Named proof atoms in `tests/authorization/post_submit/`: + +- `test_terminal_replay_validates_both_stored_receipts_without_side_effects`: + canonical success, invalid final receipt with a valid execute receipt denies, + unchanged audit count and zero additional provider opens. +- `test_materialization_rejects_substituted_live_lease_before_io`: well-typed + independently changed lease IDs/generations/expiry deny before any protected + access; canonical control succeeds. +- `test_materialization_rejects_mixed_valid_stored_lineage_before_io`: two valid + graphs with shared service actors; mixed selectors deny without side effects. +- `test_revoked_after_consumer_cannot_publish[workstream.artifact.materializer]`: paused consumer + and one provider read; materializer revocation denies its post-I/O receipt check. +- `test_revoked_after_consumer_cannot_publish[workstream.checker.post_submit]`: materializer remains + active and completes revalidation; checker revocation denies finalization. +- `test_execution_identity_substitution_denies_before_material_access`: typed + request, project, attempt, result and generation substitution with valid controls. +- `test_outer_deadline_revalidates_material_after_cleanup`: actual executor deadline + after consumer entry; revoked authority or changed replica denies after cleanup, + while unchanged custody permits the existing infrastructure-failure outcome. +- `test_outer_deadline_cannot_hide_failed_scratch_cleanup`: cancellation during + prepared release, extraction-workspace or projection cleanup propagates the + integrity failure, retains + cleanup ownership and the running attempt, and publishes no terminal evidence. + The same retained cleanup can succeed afterward. Ordinary callback errors + after abort remain distinct: successful cleanup preserves cancellation or the + preparation deadline, so post-I/O material authority is still revalidated. + `test_submission_processing_preserves_cancellation_over_aborted_callback_error` + protects that shared pre/post owner contract. Cleanup lock deadlines are + sanitized as scratch-integrity failures, not recordable provider failures; + callback, projection, workspace and prepared-source ownership remain distinct. +- `test_revocation_and_finalization_serialize`: independent sessions and + observed lock waiting prove both valid commit/denial orderings. +- `test_real_audit_insert_failure_rolls_back[execute/materialize/finalize]`: action-filtered real + insert failures, observed trigger execution, exact rollback and valid control + after trigger cleanup. +- Convert `test_finalization_outbox_failure_rolls_back` to real AUTH and inspect + staged final audit/member rows before failing the actual completion insert. +- `test_actual_upgrade_preserves_or_refuses_without_repair[False/True]`: 0009 -> 0010 migration + snapshot preservation/refusal. Pin the existing 0008 -> 0009 material tests to + `0009_checker_material_lineage`, keeping their distinct preservation proof. + +The 0010 writer-exclusion regression pauses the real migration after preflight, +observes an independent old-schema writer waiting on its table lock, then proves +that writer cannot commit an unproven receipt after upgrade. A real authorized +claim remains usable afterward. Earlier material-selection tests call the ART +selection owner directly when proving ART rejection, so a prior CHECKERS lease +guard cannot mask the intended assertion. + +The shared scope validator factors the existing non-artifact selector rejection +once, preserving its error and allowed combinations without growing the large +runtime module. Catalogue and migration-graph assertions remain exact. + +Each boundary receives a discriminating removal/substitution probe as described +below; fixture or unrelated type-validation failures do not count. + +1. Exact valid execution and finalization persist the exact respective AUTH + receipt with the protected state. Real Local and MinIO execution retain all + canonical material facts; current zero-output behavior remains explicit. +2. Missing/inactive/revoked service and foreign service/action combinations deny. + Human/dispatcher/output identities gain no checker permissions. Verify SQL + closed-identity and audit action/permission/resource parity after upgrade. +3. Independently substitute request, project, submission, attempt, result, lease + ID/generation/expiry or evaluation generation, including mixed identifiers + from a second valid stored lineage. Deny before provider/scratch/consumer when + the defect is known at entry; leave run, audit and event state unchanged. +4. Exact terminal replay returns original IDs and validates original execute and + finalize receipts with no extra audit/provider/product effect. Substituted + action, principal, resource digest or stored receipt denies. Revoke after + success and prove replay denies rather than borrowing the old allow. +5. Revoke after provider/evaluator work and prove fresh finalization denies with + no members, final receipt, result/material/routing or completion event. Keep + the committed running lease and execute receipt. Prove both independent- + session orderings: finalization custody before revocation commits first; + revocation first causes denial. Reuse real-AUTH stale-worker takeover and + fence-generation serialization tests without lock-order reversal. +6. Fail the actual AUTH audit insert using an ordinary action-filtered test + trigger/function installed on audit_events and removed in finally. + Execute failure leaves the run queued with no lease or execute receipt; + materialization failure prevents provider and scratch access; finalization + failure leaves the committed running lease and execute receipt intact while + all terminal writes roll back. Separately fail completion-event + insertion after real AUTH evidence is staged and prove that evidence, members + and final state roll back together. These are distinct failure boundaries. +7. Preserve scratch cleanup and no-open-transaction behavior on cancellation and + provider failure. Materialization audit may remain as evidence of an authorized + read; it never substitutes for a CHECKERS finalization receipt. +8. Direct SQL rejects nonexistent receipt IDs and valid stored allows from a + wrong action, principal, project/attempt or prior lease, including same-attempt + wrong result/material digests and a finalize receipt chained to another + execute receipt. Otherwise valid controls commit. Reject at the semantic + receipt boundary, not an unrelated missing-field guard; preserve the prior + run and roll back staged members/event/evidence. Compare Python/SQL digests + for execute, completed finalize and infrastructure-failed finalize, with null + and non-null material. Upgrade preserves valid/queued facts and refuses + unprovable retained receipts atomically. +9. Mutation probes must disable the specific request/lease/replay/revocation + check and fail at the intended behavioral assertion. Do not count fixture, + missing-field or unrelated guard failures as regression proof. + +The final-receipt validator independently rejects a missing execute receipt. +Its direct-SQL regression isolates that semantic guard from the earlier run-state +trigger, supplies an otherwise matching finalize event/digest, and proves commit +rejection and rollback. A positive receipt-chain control commits; restoring the +old nullable comparison must make the negative assertion fail. This strengthens +receipt custody without changing the roadmap's exposure or next boundary. + +## Evidence + +Base for the reviewed plan: main `39a6b827`; installed migration head 0009. +The focused proof modules are under `tests/authorization/post_submit/`. Run focused PostgreSQL cases via the existing +`backend/scripts/run_isolated_tests.py` isolated database runner, including the +new AUTH suite, `tests/checkers/execution/`, +`tests/test_post_submit_materialization.py`, and affected catalogue/PREP/schema +suites. Real MinIO cases use the existing service fixture. Run affected Ruff, +`git diff --check`, module-boundary/behavior-ownership/test-structure validators, +markdown links, stale Workstream/authorization/artifact wording and Commitrail +validation. Complete hosted backend lanes and aggregate manifest with no skipped +or deselected tests; coverage remains diagnostic. + +## Risk and review routing + +Required focused plan and candidate tracks: security; architecture/reuse; +QA/test delta; documentation/product operations; CI integrity; senior engineering +for phase/lock/replay simplicity. Lead owns shared checks and exact candidate +custody. Plan reviews inspect feasibility before code, not claim runtime proof. + +Human review focus: exact service matrix and bounded audit facts, database receipt +provenance, replay's stored +receipt validation, short transaction lock order, revoked worker finalization, +real audit rollback, and accurate unavailable output/dispatch/public boundaries. + +## Remaining boundary + +After this change, ARCH-04E coordinates automatic dispatch and governed outcomes; +04F supplies contributor remediation; shared final acceptance is still required +for `human_review_required=false`; public intake remains gated by that complete +path. Human review is still the default. Do not start these chunks automatically +inside this PR or claim the full public lifecycle is delivered. diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md b/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md index 0e236bc83..e45e0d5e1 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md @@ -4,7 +4,7 @@ Use the [current dependency contract](PLAN.md#current-dependency-contract). The [preserved map](../pre-cutover/CHUNK_MAP.md) retains the complete original work accounting. Foundations through 02H and CP04B are complete; none restart. AUTH-18 public manager activation/context and ARCH-03D hidden approved-guide intake are delivered. -ARCH-04B hidden exact post-submit input and ARCH-04B2 output custody are delivered. ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody is delivered; ARCH-04D2 live authority is next; +ARCH-04B hidden exact post-submit input and ARCH-04B2 output custody are delivered. ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical material custody is delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next; public intake remains deferred to ARCH-02I. | Boundary | Owner outcome | Risk | Current dependency | @@ -32,16 +32,16 @@ public intake remains deferred to ARCH-02I. | [WS-ARCH-001-03C4](../WS-ARCH-001-03C4.md) | Exact-authorized public task queues | L1 | Complete; contributor, manager and operational projections with signed pagination | | [WS-ARCH-001-03C5](../WS-ARCH-001-03C5.md) | Exact-authorized Contributor and Manager detail and requirements | L1 | Complete; canonical projections, historical policy custody and atomic read evidence | | [WS-ARCH-001-03C6](../WS-ARCH-001-03C6.md) | Distinct exact-authorized locked-context reads | L1 | Complete; bounded Audit Authority history access delivered by 03C7 | -| [WS-ARCH-001-03C7](../WS-ARCH-001-03C7.md) | Exact-authorized bounded task history | L1 | Complete; AUTH-18 public guide activation, ARCH-03D hidden approved-guide intake, hidden exact post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 next | +| [WS-ARCH-001-03C7](../WS-ARCH-001-03C7.md) | Exact-authorized bounded task history | L1 | Complete; AUTH-18 public guide activation, ARCH-03D hidden approved-guide intake, hidden exact post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 authority delivered; ARCH-04E1A next | | [WS-ARCH-001-03C](chunks/WS-ARCH-001-03C-auth-task-readiness.md) | Exact task/assignment public activation and integrated readiness proof | L1 | Complete through 03C7 audit history, 03C4 queues, 03C5 detail/requirements and 03C6 locked-context reads; AUTH-18 public guide activation delivered; ARCH-03D hidden intake delivered; public intake cutover remains separate | | [WS-ARCH-001-04A](../WS-ARCH-001-04A.md) | CHECKER post-submit contract and registered evaluator conformance | L1 | Complete canonical catalogue, phase facts and structural conformance; consumed by delivered POL-04B and POL-07B | -| [WS-ARCH-001-04B](../WS-ARCH-001-04B.md) | ART exact verified Submission materialization | L1 | Complete hidden verified input with async scoped reads; production authority remains deny-only until 04D2 | +| [WS-ARCH-001-04B](../WS-ARCH-001-04B.md) | ART exact verified Submission materialization | L1 | Complete hidden verified input with async scoped reads; exact input authority delivered by 04D2 | | [WS-ARCH-001-04B2](../WS-ARCH-001-04B2.md) | ART checker-output custody and verified binding | L1 | Complete hidden typed store, byte-free recovery and flush-only binding; production authority deny-only | -| [WS-ARCH-001-04C](chunks/WS-ARCH-001-04C-checker-current-result.md) | CHECKER hidden durable current output and supersession behavior | L1 | Complete hidden request/lease/result custody with exact empty-output support and atomic completion; production remains deny-only | +| [WS-ARCH-001-04C](chunks/WS-ARCH-001-04C-checker-current-result.md) | CHECKER hidden durable current output and supersession behavior | L1 | Complete hidden request/lease/result custody with exact empty-output support and atomic completion; exact service authority delivered by 04D2 | | [WS-ARCH-001-04D1](../WS-ARCH-001-04D1.md) | Canonical terminal ART material custody | L1 | Complete; valid retained history preserved; invalid upgrades refused | -| [WS-ARCH-001-04D2](chunks/WS-ARCH-001-04D-auth-post-submit-activation.md) | AUTH exact fixed-service post-submit activation (replaces XINT-06B) | L1 | Planned after 04D1 | +| [WS-ARCH-001-04D2](../WS-ARCH-001-04D2.md) | AUTH exact fixed-service post-submit activation (replaces XINT-06B) | L1 | Complete: exact input, execute and finalize authority; output write/bind remains unavailable | | [WS-ARCH-001-04E](chunks/WS-ARCH-001-04E-canonical-allow-review.md) | TASK current routing: true to canonical `allow_review`, false/pass to shared acceptance | L1 | Source 04E1A -> hidden handlers 04E1B -> AUTH 04E2 -> live 04E3, plus 04D2/OUTBOX-02; false consumes shared REV/CON/fence proof and activation also requires 04F remediation | -| [WS-ARCH-001-03D](../WS-ARCH-001-03D.md) | Exact activated historical guide through hidden durable intake; obsolete lookup removed | L1 | Complete; hidden exact post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 next, public cutover remains deferred | +| [WS-ARCH-001-03D](../WS-ARCH-001-03D.md) | Exact activated historical guide through hidden durable intake; obsolete lookup removed | L1 | Complete; hidden exact post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 authority delivered; ARCH-04E1A next, public cutover remains deferred | | [WS-ARCH-001-04F](chunks/WS-ARCH-001-04F-checker-remediation.md) | Contributor-correctable checker failures and same-lineage admission-backed replacement Submission | L1 | Planned after 04E; replaces XINT-05C, required before public 02I, not before REV begins from `allow_review` | CP09, 04E and 04F are coordination parents, not permission for multi-owner PRs. diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md b/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md index 49ca285f9..a06b451e5 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md @@ -44,15 +44,15 @@ checker-remediation boundary before public Submission cutover. | [ARCH-03C4](../WS-ARCH-001-03C4.md) | ARCH-03C3 | Complete: exact-authorized contributor, manager and operational public queues | | [ARCH-03C5](../WS-ARCH-001-03C5.md) | ARCH-03C4 | Complete: exact-authorized Contributor/Manager detail and requirements | | [ARCH-03C6](../WS-ARCH-001-03C6.md) | ARCH-03C5 | Complete: distinct exact-authorized locked-context reads | -| [ARCH-03C7](../WS-ARCH-001-03C7.md) | ARCH-03C6 and hidden TASK owner contracts | Complete: bounded Audit Authority history access; public guide activation, ARCH-03D hidden intake, hidden post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 next | -| [CP05A](../WS-ARCH-001-CP05A.md) | CP05, existing policy owners | Complete: public Finance policy administration and selector recovery; public manager activation, ARCH-03D hidden intake, hidden post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 next | +| [ARCH-03C7](../WS-ARCH-001-03C7.md) | ARCH-03C6 and hidden TASK owner contracts | Complete: bounded Audit Authority history access; public guide activation, ARCH-03D hidden intake, hidden post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 authority delivered; ARCH-04E1A next | +| [CP05A](../WS-ARCH-001-CP05A.md) | CP05, existing policy owners | Complete: public Finance policy administration and selector recovery; public manager activation, ARCH-03D hidden intake, hidden post-submit materialization and ARCH-04B2 output custody delivered; ARCH-04C hidden execution delivered; ARCH-04D1 canonical custody delivered; ARCH-04D2 authority delivered; ARCH-04E1A next | | CP09 (later cleanup coordination) | All legacy consumers replaced, including CHECKER and public 02I path | Physical economic deletion; not on the allow_review critical path | | [ARCH-03D](../WS-ARCH-001-03D.md) | AUTH-18, ARCH-03A, CP08 and merged ART preparation | Complete: hidden durable intake uses exact historical TASK/PROJECTS ports; public cutover remains ARCH-02I | -| ARCH-04B | ARCH-04A, POL-07, ARCH-03C, merged ARCH-02H | Complete: ART hidden verified Submission input; live authority remains deferred | +| ARCH-04B | ARCH-04A, POL-07, ARCH-03C, merged ARCH-02H | Complete: ART hidden verified Submission input; exact live authority is delivered by ARCH-04D2 | | [ARCH-04B2](../WS-ARCH-001-04B2.md) | ARCH-04A and merged ART admission/verification/binding foundations | Complete: hidden bounded checker-output store/recovery and verified binding, no routing or live reservation | | ARCH-04C | ARCH-04A, ARCH-04B, delivered ARCH-04B2, POL-07 | Complete hidden CHECKERS execution/result/currentness and worker recovery, including exact empty output sets | | [ARCH-04D1](../WS-ARCH-001-04D1.md) | ARCH-04B, ARCH-04C | Complete: canonical ART custody for terminal evidence retaining material | -| ARCH-04D2 | ARCH-04D1 | Activate exact AUTH post-submit services | +| [ARCH-04D2](../WS-ARCH-001-04D2.md) | ARCH-04D1 | Complete: exact AUTH input, execution and finalization; no output-file authority | | AUTH-OUTBOX-01 | Merged shared outbox persistence and AUTH service/PREP foundations | Complete: planned dispatcher identity/action/matrix and unavailable typed authority contract | | CON-02B | AUTH-OUTBOX-01 | Complete: shared hidden dispatcher/claim fencing, typed handlers and recovery | | AUTH-OUTBOX-02 | CON-02B exact hidden manifest | Exact dispatcher mechanics only; no feature authority | @@ -88,16 +88,17 @@ ARCH-03C1 completes exact reconciler authority and decision-bound receipts. ARCH-03C2 supplies originating-transaction-only per-assignment producer events and first handler registration with enforced prefork topology; it must never backfill or dispatch retained invalidation rows. Public TASK activation is complete through ARCH-03C7. AUTH-18 delivers public -manager guide activation/context; ARCH-03D hidden intake, hidden exact post-submit materialization and ARCH-04B2 output custody are delivered; ARCH-04C hidden execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 is next. Subsequent +manager guide activation/context; ARCH-03D hidden intake, hidden exact post-submit materialization and ARCH-04B2 output custody are delivered; ARCH-04C hidden execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 authority is delivered; ARCH-04E1A routing-source facts are next. Subsequent PR-sized contracts name exact files, public types, current migration head and runnable proof before implementation; they refine this design, not create a new permission requirement. ### Supporting foundations required by automatic routing -Current supporting contracts are delivered [ARCH-04B2](../WS-ARCH-001-04B2.md), +Delivered supporting foundations are [ARCH-04B2](../WS-ARCH-001-04B2.md), [AUTH-OUTBOX-01/02](../../WS-AUTH-001/planning/PLAN.md#ws-auth-001-outbox-01--unavailable-dispatcher-contract), -[CON-02B](../../WS-CON-001/OVERVIEW.md#con-02b-current-dispatcher-contract), and +and [CON-02B](../../WS-CON-001/OVERVIEW.md#con-02b-current-dispatcher-contract). +The remaining routing sequence is [ARCH-04E1A/04E1B/04E2/04E3](chunks/WS-ARCH-001-04E-canonical-allow-review.md#current-bounded-sequence). Each numbered section is a current bounded design, expanded into its own change record on implementation; the parent is not a multi-owner implementation PR. @@ -129,8 +130,10 @@ structural catalogue reserves zero output slots; controlled nonempty fixtures prove ART mechanics only. ARCH-04C finalizes only that exact empty output set, using the [bounded execution contract](../WS-ARCH-001-04C.md). A future registered output-producing capability must add real producer and atomic binding proof -before activation; controlled nonempty fixtures do not establish that capability. ARCH-04D2 then activates the exact ART write/binding -and CHECKERS completion surfaces. In that planned flow, external byte I/O occurs +before output authority activation; controlled nonempty fixtures do not establish +that capability. [ARCH-04D2](../WS-ARCH-001-04D2.md) activates only current +materialization, execution and finalization authority. A later real output +producer owns write/binding activation. In that planned output flow, external byte I/O occurs before the final caller transaction; binding publication and final result become visible atomically. Failed storage never becomes contributor blame or an `allow_review` result. @@ -186,15 +189,14 @@ owning implementation, not with planning prose or fake database claims. ARCH-04B input proof is delivered in `test_post_submit_materialization.py` and `test_post_submit_selection.py`, including deny-before-I/O composition. ARCH-04B2 -delivers output custody proof; ARCH-04C delivers hidden durable execution proof. Live authority proof remains with ARCH-04D2. The delivered +delivers output custody proof; ARCH-04C delivers hidden durable execution proof. ARCH-04D2 supplies exact input/execution/finalization authority and durable receipt custody. The delivered [ARCH-04D1 canonical material custody](../WS-ARCH-001-04D1.md) requires independent admission/replica/manifest substitution rejection at durable finalization before activation. -These are required future implementation tests, not tests claimed present or -executed by this planning PR. Each owner's bounded record fixes the final -module path alongside implementation; the symbols preserve the behavioral -obligation. Run them through `cd backend && uv run pytest ` +The delivered 04C/04D2 row names implemented proof. Other rows retain future +implementation obligations; each owner's bounded record fixes its final module +path and compatible execution evidence. Run them through `cd backend && uv run pytest ` and the unchanged hosted suite/coverage gates. Database, I/O and concurrency claims require their real custody, not unit substitutes. @@ -206,7 +208,7 @@ claims require their real custody, not unit substitutes. | ARCH-04A/POL-07 | `test_registered_evaluator_rejects_invalid_work`, `test_checker_facade_delegates_once` | Actual registered evaluator fixtures and typed composition; presence-only mutant must fail | | CP06/CP07/AUTH-12H | `test_activate_without_legacy_payment_or_task`, `test_activation_requires_exact_selected_policy`, `test_activation_rejects_missing_review_revision_config` | PostgreSQL atomic command plus full response serialization; foreign/retired/incomplete new binding denies | | CP08/ARCH-03A/03B/03C | `test_ready_preserves_screening_policy_lock`, `test_claim_copies_policy_without_current_lookup` | PostgreSQL and actual AUTH/owner composition; later publication leaves existing attempt unchanged | -| ARCH-04C/04D2 | `test_late_revocation_cannot_publish_result`, `test_unfinished_checker_recovery_reuses_attempt`, `test_terminal_retry_requires_operator_and_new_attempt` | Consume delivered 04B2 custody; Local/MinIO, real worker/provider contract and PostgreSQL races; independent sessions and staged/final state | +| ARCH-04C/04D2 | `test_revoked_after_consumer_cannot_publish`, `test_outer_deadline_revalidates_material_after_cleanup`, `test_terminal_replay_validates_both_stored_receipts_without_side_effects`, `test_stale_worker_cannot_finalize_after_takeover` | Consume delivered 04B2 custody; Local/MinIO, real worker/provider contract and PostgreSQL races; independent sessions and staged/final state | | ARCH-04E | `test_submission_to_current_allow_review`, `test_superseded_run_cannot_route`, `test_duplicate_dispatch_has_one_manifest`; false tests in the shared acceptance contract | Real DB/worker/storage path, exact authority-event references; true creates no acceptance, false creates the shared atomic acceptance with no human Review | Owner-local schema names and migrations are chosen from the then-current diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-04D-auth-post-submit-activation.md b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-04D-auth-post-submit-activation.md index 37e3b4464..90fd72f22 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-04D-auth-post-submit-activation.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-04D-auth-post-submit-activation.md @@ -1,10 +1,11 @@ # Chunk Contract: WS-ARCH-001-04D AUTH Post-Submit Activation -Status: ARCH-04D1 canonical custody is Complete; ARCH-04D2 authority remains a -non-executable planning skeleton after exact 04B/04C manifests. +Status: ARCH-04D1 canonical custody is Complete; ARCH-04D2 is Complete under +the [bounded authority record](../../WS-ARCH-001-04D2.md). Risk: L1. Outcome: ARCH-04D2 activates exact fixed-service materialization, -checker-output, evaluator execution and finalization boundaries, replacing -the historical XINT-06B/broad AUTH-14 design. +evaluator execution and finalization boundaries, replacing the historical +XINT-06B/broad AUTH-14 design. Output ingestion and binding remain unavailable +until a real registered checker produces output files. ARCH-04D2 is the sole current activation contract; XINT-06B and broad AUTH-14 are historical custody references, not parallel work. Materialization PREP @@ -30,25 +31,26 @@ preserves valid retained rows and refuses unprovable rows without backfill. 04D1 proves independent foreign admission/replica/manifest substitutions and transaction rollback with controlled phase participants. It does not prove real -AUTH finalization audit custody. ARCH-04D2 must supply that atomicity proof and -live authorization tests before any service activation. No CHECKERS runtime +AUTH finalization audit custody. ARCH-04D2 supplies that atomicity proof and +real authorization tests for exact service activation. No CHECKERS runtime private ART query, alternate store or compatibility path is introduced. -## Proposed CHECKERS service manifest +## CHECKERS service manifest -ART's three existing actions cover materialization, output ingestion and -binding only; none authorizes a CHECKERS run/result write. Propose one exact +ART's existing actions cover materialization, output ingestion and binding; +none authorizes a CHECKERS run/result write. Only materialization is needed by +the current zero-output catalogue. Register one exact fixed identity `workstream.checker.post_submit` with two action/permission pairs: `checker.post_submit.execute` for attempt execution/pre-I/O admission and `checker.post_submit.finalize` for final-result persistence. Register their -typed contexts, static matrix, admission and unavailable seams explicitly; -activate only after the hidden 04C behavior manifest. No human or outbox +typed contexts, static matrix and exact admitted phases through the existing +AUTH/PREP service after the hidden 04C behavior manifest. No human or outbox dispatcher receives these service-only permissions. The existing ART materializer/output identities retain their separate actions; do not collapse them into this evaluator identity. Finalization requires fresh authority after I/O, exact current request/fence, -accepted-result digest and verified declared output bindings. The preflight +accepted-result digest, canonical material and the current empty output tuple. The preflight allow does not authorize final persistence. Operator terminal retry retains its separately governed recovery action and cannot impersonate ordinary execution. Negative proof covers each service attempting the other's actions, @@ -66,17 +68,22 @@ cross-resource, mismatched replay, copied-handle or revoked requests detected before I/O deny before protected side effects. Fresh validation after I/O suppresses final-current result and routing writes if authority or lineage changed; it cannot undo earlier authorized reads/evaluator calls. Exact valid -replay returns the same stored identity with no duplicate effects; it never -borrows an earlier allow in place of current authority. Evidence commits -atomically with its protected write. Verify catalogue/database parity, +replay validates the stored phase-specific authorization receipt under fresh +authority and returns the same identity without duplicate audit or product +effects; it never borrows an earlier allow in place of current authority. Execute/finalize evidence commits +atomically with its protected write. Materialization evidence commits before +provider access and may remain after a subsequently failed authorized read. Verify catalogue/database parity, PostgreSQL races, boundary validators, Ruff and hosted coverage. Required reviews: authorization architecture, security, product/ops, QA, senior, CI and test delta. -Before ARCH-04D2 implementation, replace its authority skeleton with a current-main contract that -enumerates exact files, commands, migration head and reviewers. +The [04D2 record](../../WS-ARCH-001-04D2.md) defines current files, strict +execution/materialization facts, lock ordering, stored-receipt replay, migration +head, commands and reviewers. No database transaction or PREP handle crosses +provider/evaluator I/O. A race after an authorized read cannot undo that read; +fresh currentness and authority prevent the stale worker publishing a result. ## Merge state - ARCH-04D1: `Complete`. -- ARCH-04D2: `Planned`. +- ARCH-04D2: `Complete`. diff --git a/.commitrail/initiatives/WS-ART-001/OVERVIEW.md b/.commitrail/initiatives/WS-ART-001/OVERVIEW.md index 44921a42e..53bbc0eb4 100644 --- a/.commitrail/initiatives/WS-ART-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-ART-001/OVERVIEW.md @@ -10,8 +10,11 @@ and the [capability ledger](../../../docs/roadmap_status.md). consumption, and binding dependencies are merged through ARCH-02H. - Completed boundary: ARCH-04B hidden input materialization and hidden [ARCH-04B2 checker-output custody](../WS-ARCH-001/WS-ARCH-001-04B2.md). - The CHECKERS zero-slot reservation reader is implemented; production authority remains deny-only. -- Next usable boundary: ARCH-04D2 exact service authority after delivered ARCH-04D1 canonical material custody and ARCH-04C hidden durable execution. + The CHECKERS zero-slot reservation reader is implemented; checker-output + write/bind authority remains deny-only. +- Next usable boundary: ARCH-04E1A routing-source facts after delivered + [ARCH-04D2](../WS-ARCH-001/WS-ARCH-001-04D2.md) exact input, execute and finalize authority. + Output-file authority remains unavailable for the zero-output catalogue. - Governing sources: artifact specifications, `ArtifactStore`, `ArtifactScratchManager`, code, migrations, and artifact tests. - Preserve: SHA-256/byte-count identity, reread verification, isolation, diff --git a/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md b/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md index 2bdfced8e..c04af7f72 100644 --- a/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md @@ -23,8 +23,9 @@ Historical pre-cutover work records: [`STATUS.md`](pre-cutover/STATUS.md), - Public post-policy composition: POL-06B delivered using existing AUTH-12G. - Completed activation boundary: AUTH-12H exact-project manager authority for CP07 complete-guide activation/binding, with live-authority replay. -- Next usable boundary: ARCH-04D2 exact service authority after delivered ARCH-04D1 canonical material custody and ARCH-04C hidden evaluation and - hidden ARCH-04B input materialization and [ARCH-04B2 checker-output custody](../WS-ARCH-001/WS-ARCH-001-04B2.md), then live authority. [AUTH-18](../WS-AUTH-001/WS-AUTH-001-18.md) delivers +- Next usable boundary: ARCH-04E1A routing-source facts after delivered + [ARCH-04D2](../WS-ARCH-001/WS-ARCH-001-04D2.md) exact input, execute and finalize authority. + Output-file authority remains unavailable for the zero-output catalogue. [AUTH-18](../WS-AUTH-001/WS-AUTH-001-18.md) delivers public manager activation context and exact guide activation. [CP05A](../WS-ARCH-001/WS-ARCH-001-CP05A.md) delivers public Finance ContributionPolicy administration and recovery of a draft selector, a published @@ -77,5 +78,5 @@ manager proposal review, pre-submit approval and manual correction dispatch. AUTH-OUTBOX-01/02 bracket hidden CON-02B dispatch; ARCH-04E2 activates only the proven TASK routing handler before ARCH-04E3 live composition. TASK queue/read exposure is complete through ARCH-03C7. AUTH-18 public - manager guide activation/context is delivered; ARCH-03D hidden intake, hidden exact post-submit materialization and ARCH-04B2 output custody are delivered; ARCH-04C hidden execution is delivered; ARCH-04D1 canonical material custody is delivered; ARCH-04D2 live authority is next. + manager guide activation/context is delivered; ARCH-03D hidden intake, hidden exact post-submit materialization and ARCH-04B2 output custody are delivered; ARCH-04C hidden execution is delivered; ARCH-04D1 canonical material custody is delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next. Remaining work must use its exact owner, not the superseded broad designs. diff --git a/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md b/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md index b3d7a3ec8..c23036bb9 100644 --- a/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md +++ b/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md @@ -13,8 +13,8 @@ work and historical proposals. | [AUTH-12H](../WS-AUTH-001-12H.md) | Complete: exact manager authority for CP07; AUTH-18 exposes manager activation and selections publicly | | CP05 | Complete: exact five policy actions; public Finance exposure delivered by CP05A | | ARCH-03C | Complete through 03C7: task/assignment authority, public queues, projections and audit reads; replaces broad AUTH-13 | -| [AUTH-18](../WS-AUTH-001-18.md) | Complete: public manager activation/context over CP07 and AUTH-12H; ARCH-03D hidden intake, ARCH-04B input and ARCH-04B2 output custody are delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 live authority is next | -| ARCH-04D2 | AUTH materialization/output plus CHECKERS execute/finalize activation after ARCH-04B/04B2/04C/04D1; replaces AUTH-14/XINT-06B | +| [AUTH-18](../WS-AUTH-001-18.md) | Complete: public manager activation/context over CP07 and AUTH-12H; ARCH-03D hidden intake, ARCH-04B input and ARCH-04B2 output custody are delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next | +| [ARCH-04D2](../../WS-ARCH-001/WS-ARCH-001-04D2.md) | Complete: exact materialization and execute/finalize authority; output write/bind unavailable; replaces AUTH-14/XINT-06B | | [AUTH-OUTBOX-01](PLAN.md#ws-auth-001-outbox-01--unavailable-dispatcher-contract) | Complete: unavailable exact dispatcher identity/action/phase contract; CON-02B and AUTH-OUTBOX-02 mechanics complete; feature authority/registration remain separate | | [AUTH-OUTBOX-02](PLAN.md#ws-auth-001-outbox-02--exact-dispatcher-activation) | Complete: exact dispatcher mechanics activation, phase audit custody and bounded prefork delivery; ARCH-03C2 subsequently registers assignment invalidation, while future handlers require their own exact authority | | [ARCH-04E2](../../WS-ARCH-001/planning/chunks/WS-ARCH-001-04E-canonical-allow-review.md#current-bounded-sequence) | Exact TASK routing handler authority after hidden 04E1, before live 04E3 | diff --git a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md index 137c7fa7f..28d9082cf 100644 --- a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md +++ b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md @@ -25,15 +25,15 @@ AUTH-13/14 cutovers are not additional implementation work. public TASK activation is complete through ARCH-03C7. - [AUTH-18](../WS-AUTH-001-18.md) delivers public manager activation and exact selection discovery over CP07/AUTH-12H. ARCH-03D completes hidden approved-guide - intake; ARCH-04B hidden exact post-submit materialization and ARCH-04B2 output custody are delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 live authority is next. + intake; ARCH-04B hidden exact post-submit materialization and ARCH-04B2 output custody are delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next. - CP05 owns exact ContributionPolicy-action activation after merged CP04B. - CP08 delivered the minimal lineage writers. ARCH-03B8 hidden task audit evidence and 03B9 hidden assignment invalidation are complete. ARCH-03C delivered public TASK activation through 03C7, after 03C1 exact feature authority and 03C2 originating-transaction event production/registration. [AUTH-13 is superseded](chunks/WS-AUTH-001-13-task-assignment-cutover.md). -- ARCH-04D2 alone activates post-submit materialization/output and CHECKERS - execution/finalization after ARCH-04B/04B2/04C; historical AUTH-14 and XINT-06B +- ARCH-04D2 activates exact post-submit materialization and CHECKERS + execution/finalization after ARCH-04B/04B2/04C/04D1; output write/bind remains unavailable; historical AUTH-14 and XINT-06B cannot start in parallel. ARCH-03B/03C explicitly own task queue/read and pre-submit invalidation; downstream REV queue/reconciliation remains separate. @@ -53,7 +53,7 @@ and public JSON-packet Submission creation. Admission-backed creation stays hidd ARCH-03B/03C reuse these exact actions and command owners; public TASK access and authority are delivered through 03C7, with invalidation wiring in 03C2. CP08 delivered ContributionPolicyVersion lineage. ARCH-03D completes hidden approved-guide intake. ARCH-04B hidden exact post-submit -input and ARCH-04B2 output custody are delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 live authority is next; public intake remains deferred to ARCH-02I. Do not restore eligibility +input and ARCH-04B2 output custody are delivered; ARCH-04C hidden durable execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next; public intake remains deferred to ARCH-02I. Do not restore eligibility or register replacement aliases. ## WS-AUTH-001-OUTBOX-01 — unavailable dispatcher contract diff --git a/.commitrail/initiatives/WS-AUTH-003/OVERVIEW.md b/.commitrail/initiatives/WS-AUTH-003/OVERVIEW.md index 135e302c3..fb80d9a1a 100644 --- a/.commitrail/initiatives/WS-AUTH-003/OVERVIEW.md +++ b/.commitrail/initiatives/WS-AUTH-003/OVERVIEW.md @@ -8,7 +8,7 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), - Completed boundary: recovery foundation and [TASK/checker authorization cleanup](WS-AUTH-003-TASKCHECKER.md). - Intent: route public authorization capability through `authorization.api` and remove cross-module repository/model coupling. -- Next usable boundary: ARCH-04D2 exact service authority after delivered ARCH-04C hidden execution and ARCH-04B +- Next usable boundary: ARCH-04E1A routing-source facts after delivered ARCH-04D2 exact service authority, ARCH-04C hidden execution and ARCH-04B hidden input, [ARCH-04B2 output custody](../WS-ARCH-001/WS-ARCH-001-04B2.md), ARCH-03D hidden intake and [AUTH-18 public manager activation](../WS-AUTH-001/WS-AUTH-001-18.md). Submission/checker history uses canonical authority; the alternate gate lifecycle diff --git a/.commitrail/initiatives/WS-CON-001/OVERVIEW.md b/.commitrail/initiatives/WS-CON-001/OVERVIEW.md index fa50a4fb4..07a3fd0f7 100644 --- a/.commitrail/initiatives/WS-CON-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CON-001/OVERVIEW.md @@ -11,8 +11,9 @@ and the [capability ledger](../../../docs/roadmap_status.md). immutable ContributionRecords and optional project-policy-driven compensation awards without coupling lifecycle truth to an economic provider. -- Next usable boundary: ARCH-04D2 exact service authority after delivered ARCH-04C hidden evaluation and - hidden ARCH-04B input materialization and [ARCH-04B2 checker-output custody](../WS-ARCH-001/WS-ARCH-001-04B2.md), then live authority. [AUTH-18](../WS-AUTH-001/WS-AUTH-001-18.md) delivers +- Next usable boundary: ARCH-04E1A routing-source facts after delivered + [ARCH-04D2](../WS-ARCH-001/WS-ARCH-001-04D2.md) exact input, execute and finalize authority. + Output-file authority remains unavailable for the zero-output catalogue. [AUTH-18](../WS-AUTH-001/WS-AUTH-001-18.md) delivers public manager activation context and exact guide activation. [CP05A](../WS-ARCH-001/WS-ARCH-001-CP05A.md) delivers public Finance ContributionPolicy administration and recovery of a draft selector, a published diff --git a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md index 749a45680..d76afa709 100644 --- a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md +++ b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md @@ -24,11 +24,13 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), post-policy derivation, public complete policy read, separate approval and correction; ART-owned pre-submit reservation and completed evidence recovery without rerunning checks; one internal command per phase and removal of the standalone JSON precheck. - Production post-submit phase execution remains unavailable. + Exact hidden post-submit execution authority is delivered; automatic dispatch + and routing remain unavailable. - Intent: compile one locked guide and its policies into authoritative, versioned project behavior without circular subsystem authority. -- Next usable boundary: ARCH-04D2 exact service authority after delivered ARCH-04D1 canonical material custody and ARCH-04C hidden evaluation and - hidden ARCH-04B input materialization and [ARCH-04B2 checker-output custody](../WS-ARCH-001/WS-ARCH-001-04B2.md), then live authority. [AUTH-18](../WS-AUTH-001/WS-AUTH-001-18.md) delivers +- Next usable boundary: ARCH-04E1A routing-source facts after delivered + [ARCH-04D2](../WS-ARCH-001/WS-ARCH-001-04D2.md) exact input, execute and finalize authority. + Output-file authority remains unavailable for the zero-output catalogue. [AUTH-18](../WS-AUTH-001/WS-AUTH-001-18.md) delivers public manager activation context and exact guide activation. [CP05A](../WS-ARCH-001/WS-ARCH-001-CP05A.md) delivers public Finance ContributionPolicy administration and recovery of a draft selector, a published diff --git a/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md b/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md index 7a87fc822..70b22af18 100644 --- a/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md +++ b/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md @@ -29,7 +29,7 @@ post-task requirement. Historical split parents 05/06 are not extra PRs. | `WS-AUTH-001-12G` | Activate exact fixed-service projection plus PM approval/correction authority for the hidden 06A manifest. | 06A | | `WS-POL-003-06B` | Live deterministic post-submit projection/approval cutover with zero additional inference. | 06A + AUTH-12G | | `WS-POL-003-07` | One typed facade over existing ART pre and CHECKER post contracts; no post-result persistence. | 06B + ARCH-04A registered capability proof + merged ART-04B1-04B3 | -| `WS-AUTH-001-12H` / [AUTH-18](../../WS-AUTH-001/WS-AUTH-001-18.md) | Complete: exact guide activation authority and public manager activation/context over the approved unified chain. CP08 lineage and ARCH-03 task authority/projections are delivered; ARCH-03D hidden intake, ARCH-04B input and ARCH-04B2 output custody are delivered; ARCH-04C hidden execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 is next and public intake remains ARCH-02I. CP09 remains later. | POL-07 + corrected AUTH-12B2 + CP05 active ContributionPolicy behavior + CP06 validation + CP07 ProjectGuide binding | +| `WS-AUTH-001-12H` / [AUTH-18](../../WS-AUTH-001/WS-AUTH-001-18.md) | Complete: exact guide activation authority and public manager activation/context over the approved unified chain. CP08 lineage and ARCH-03 task authority/projections are delivered; ARCH-03D hidden intake, ARCH-04B input and ARCH-04B2 output custody are delivered; ARCH-04C hidden execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 authority is delivered; ARCH-04E1A routing-source facts are next and public intake remains ARCH-02I. CP09 remains later. | POL-07 + corrected AUTH-12B2 + CP05 active ContributionPolicy behavior + CP06 validation + CP07 ProjectGuide binding | | `WS-POL-003-08` | Supplementary visibility; separate remaining cleanup is parked and handled within each affected module. Essential review/correction belongs to 05A/05B and 06A/06B. | Planned after 07 + AUTH-12H + canonical WS-ARCH-001-04E manifest; any separately authorized retained-data change requires CP09's inventory, mapping and readability/recoverability proof for affected facts; no cleanup prerequisite for 04B | The 04E manifest proves canonical routing, not legacy-history preservation. @@ -53,8 +53,9 @@ mixed-generation chains deny through the existing locked-lineage checks. ## Post-submit execution gate ARCH-04A contracts/capability conformance precede POL-07 and guide activation. -ARCH-04B delivers hidden exact ART post-submit input with deny-only production +ARCH-04B delivers hidden exact ART post-submit input with exact ARCH-04D2 service authority. ARCH-04B2 hidden output custody and ARCH-04C hidden durable CHECKER execution/results with exact empty-output support are delivered. -WS-ARCH-001-04D2 is the next replacement authority activation gate. Historical +WS-ARCH-001-04D2 delivers exact input/execute/finalize authority; ARCH-04E1A +routing-source facts are next. Output-file authority remains unavailable. Historical XINT-06B and AUTH-14 contracts are superseded/non-executable. diff --git a/.commitrail/initiatives/WS-POL-003/planning/PLAN.md b/.commitrail/initiatives/WS-POL-003/planning/PLAN.md index 05354a180..d71773a1f 100644 --- a/.commitrail/initiatives/WS-POL-003/planning/PLAN.md +++ b/.commitrail/initiatives/WS-POL-003/planning/PLAN.md @@ -57,7 +57,7 @@ approval authority. AUTH-12H supplies exact guide activation authority; AUTH-18 exposes manager activation and selection discovery publicly. ARCH-03D completes hidden approved-guide intake integration. ARCH-04B hidden exact post-submit materialization and ARCH-04B2 output custody are delivered; ARCH-04C -hidden execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 live authority is next; public intake remains deferred to ARCH-02I. +hidden execution is delivered; ARCH-04D1 canonical custody is delivered; ARCH-04D2 exact input/execution/finalization authority is delivered; ARCH-04E1A routing-source facts are next; public intake remains deferred to ARCH-02I. The sequence through POL-04B is complete; POL-04B1 supplies automatic request custody for the delivered live cutover: @@ -160,9 +160,10 @@ submission. Read-only checker-run visibility also remains bounded and separate. [POL-07B](../WS-POL-003-07B.md) completes facade composition over the CHECKER contract supplied by the independent ARCH-04A boundary. ARCH-04A does not depend on POL-07, guide activation or task readiness. ARCH-04C alone implements durable post-submit -attempt/result/currentness storage and worker recovery; 07 cannot claim that -future repository proof or make it a prerequisite for guide activation. Production post composition explicitly injects the unavailable executor; later -artifact-flow integration supplies verified stored material and durable custody. The reviewed [POL-07A prerequisite](../WS-POL-003-07A.md) +attempt/result/currentness storage and worker recovery. ARCH-04D2 supplies real +fixed-service authority and exact materialization in production composition. +Automatic dispatch/routing remains ARCH-04E work; none of these runtime steps +becomes a prerequisite for guide setup or policy approval. The reviewed [POL-07A prerequisite](../WS-POL-003-07A.md) repairs ART-owned pre-submit invocation custody before the facade: a committed reservation fences execution, and completed recovery reads canonical evidence. This bounded exception does not move ART persistence into CHECKER or add diff --git a/.commitrail/initiatives/WS-POL-003/planning/chunks/WS-POL-003-07-single-checker-service-port.md b/.commitrail/initiatives/WS-POL-003/planning/chunks/WS-POL-003-07-single-checker-service-port.md index 407730eab..9dd82e5b9 100644 --- a/.commitrail/initiatives/WS-POL-003/planning/chunks/WS-POL-003-07-single-checker-service-port.md +++ b/.commitrail/initiatives/WS-POL-003/planning/chunks/WS-POL-003-07-single-checker-service-port.md @@ -22,15 +22,15 @@ the earlier overbroad execution claims in this planning chunk. Do not open a transaction at this handoff. ART's existing operation obtains fresh authority, executes the locked plan when warranted, and returns its canonical evidence unchanged. Replay invokes no members or new upload capability. -- `evaluate_post_submission` validates and delegates CHECKER's closed value - contract. Production explicitly remains unavailable. Exact request, policy, - catalogue, generation and member correspondence is value-consistency proof, - not authority, durable ownership, attempt recovery or currentness proof. -- ARCH-04B delivers hidden post-submit input with deny-only production authority. - ARCH-04B2 output custody, ARCH-04C persistence, ARCH-04D authority and ARCH-04E - event invocation/routing remain separate. Their eventual event handler invokes the facade; - its injected executor must not call the facade recursively. Existing post - callers remain until that cutover, not as a second implementation of this port. +- `evaluate_post_submission` validates and delegates CHECKERS' closed value + contract. ARCH-04C supplies the hidden durable executor and currentness; + ARCH-04D2 supplies exact input, execute and finalize service authority. + The facade's request/result correspondence remains value-consistency proof; + those owner operations enforce authorization and durable execution custody. +- ARCH-04B supplies verified input and ARCH-04B2 supplies hidden output custody. + Output write/bind authority remains unavailable for the zero-output catalogue. + ARCH-04E event invocation/routing remains separate. Its event handler invokes + the facade; the injected executor must not call the facade recursively. - Remove the standalone draft JSON precheck, its exclusive code, schemas and tests together. Preserve shared helpers used by current post-submit consumers. Broader public Submission cutover remains WS-ARCH-001-02I. diff --git a/README.md b/README.md index c826625cc..cb0403d6e 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,8 @@ Implemented foundations on `main` include external Flow-token verification, canonical local actors and authorization, project guides and task records, submission packets, immutable artifact storage, pre-submit intake checks, and authorized retained submission/checker history. ARCH-04C implements hidden -durable post-submit execution and unfinished-attempt recovery. Live authority -and automatic routing remain unavailable. Project-guide ingestion stores original documents, +durable post-submit execution and unfinished-attempt recovery. ARCH-04D2 supplies exact service authority; +automatic dispatch and routing remain unavailable. Project-guide ingestion stores original documents, records immutable metadata and provides authorized exact-file reads to the unified setup agent. Guide metadata in PostgreSQL also holds at least one required task example; the agent assesses the examples with the uploaded guide documents. @@ -553,7 +553,7 @@ Derivation, reads, approval and correction creation do not invoke inference or post-submit evaluators. A periodic scan recovers publication failures from committed approvals; duplicate delivery retains one policy and receipt. Hidden post-submission execution and completion custody are implemented; live -service authority and automatic routing remain pending ARCH-04D2/04E. +ARCH-04D2 supplies exact service authority; automatic routing remains ARCH-04E work. The local Celery command above includes Beat; start it before creating guide sources. The Beat scheduler must run alongside the Celery execution processes so @@ -568,8 +568,8 @@ checker phase service and removes the standalone JSON precheck. Both fresh pre-submit execution and completed replay use that service, with ART retaining 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 deny-only production authority; live service -authority and automatic routing remain ARCH-04D2/04E work. +uses hidden durable execution with exact ARCH-04D2 service authority; automatic +dispatch and routing remain ARCH-04E 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 @@ -658,9 +658,11 @@ 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. Production authority remains -deny-only. ARCH-04D1 validates retained terminal material against canonical ART -lineage in PostgreSQL; ARCH-04D2 activates exact services before ARCH-04E routing. +The structural catalogue produces no output files, so output write/bind authority +remains unavailable. 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. Automatic +dispatch and routing remain ARCH-04E work. ## v0.1 Success Standard diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 5dff5371a..c897490f8 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 = "0009_checker_material_lineage" +_CURRENT_HEAD_REVISION = "0010_post_submit_authority" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_uuid7_v01 migration" @@ -52,7 +52,7 @@ def do_run_migrations(connection: Connection) -> None: .scalars() .all() ) - if revisions not in ((), (_BASELINE_REVISION,), ("0002_task_queue_authority",), ("0003_task_read_authority",), ("0004_task_context_authority",), ("0005_task_evidence_authority",), ("0006_history_read_authority",), ("0007_checker_output_custody",), ("0008_checker_execution",), (_CURRENT_HEAD_REVISION,)): + if revisions not in ((), (_BASELINE_REVISION,), ("0002_task_queue_authority",), ("0003_task_read_authority",), ("0004_task_context_authority",), ("0005_task_evidence_authority",), ("0006_history_read_authority",), ("0007_checker_output_custody",), ("0008_checker_execution",), ("0009_checker_material_lineage",), (_CURRENT_HEAD_REVISION,)): raise RuntimeError(_RECREATE_GUIDANCE) # The read-only preflight autobegins a SQLAlchemy transaction. End that # transaction before Alembic establishes the migration transaction; diff --git a/backend/alembic/versions/0010_post_submit_authority.py b/backend/alembic/versions/0010_post_submit_authority.py new file mode 100644 index 000000000..7e7522606 --- /dev/null +++ b/backend/alembic/versions/0010_post_submit_authority.py @@ -0,0 +1,158 @@ +"""Bind post-submit execution and finalization to exact immutable AUTH receipts.""" + +from alembic import op +import sqlalchemy as sa + +revision = "0010_post_submit_authority" +down_revision = "0009_checker_material_lineage" +branch_labels = None +depends_on = None + + +def _extend_constraint(table, name, anchor, replacement, count): + definition = op.get_bind().execute(sa.text( + "select 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() + if definition.count(anchor) != count: + raise RuntimeError("post-submit authority constraint shape changed: " + name) + op.execute(f"alter table public.{table} drop constraint {name}") + op.execute(f"alter table public.{table} add constraint {name} " + definition.replace(anchor, replacement)) + + +def upgrade(): + """Preserve provable custody and reject unprovable retained receipts without repair.""" + op.execute("set local search_path = pg_catalog, public, pg_temp") + op.execute("lock table public.actor_profiles, public.actor_identity_links, public.checker_runs, public.audit_events in access exclusive mode") + _extend_constraint( + "actor_profiles", "ck_actor_profiles_kind_service_identity", "ARRAY[", + "ARRAY[('workstream.checker.post_submit'::character varying)::text, ", 1, + ) + _extend_constraint( + "audit_events", "ck_audit_events_authority_registries", + "('outbox.dispatch'::character varying)::text", + "('outbox.dispatch'::character varying)::text, " + "('checker.post_submit.execute'::character varying)::text, " + "('checker.post_submit.finalize'::character varying)::text", 1, + ) + _extend_constraint( + "audit_events", "ck_audit_events_authority_privacy_bounds", + "('outbox_event'::character varying)::text", + "('outbox_event'::character varying)::text, ('checker_run'::character varying)::text", 1, + ) + anchor = "(((action_id)::text = 'project.task.create'::text) AND ((permission_id)::text = 'project.task.manage'::text))" + addition = " OR ".join( + f"(((action_id)::text = '{action}'::text) AND ((permission_id)::text = '{action}'::text))" + for action in ("checker.post_submit.execute", "checker.post_submit.finalize") + ) + _extend_constraint("audit_events", "ck_audit_events_authorization_action_evidence", anchor, anchor + " OR " + addition, 2) + _functions() + op.execute(""" + DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM public.checker_runs r + WHERE (r.execute_evidence_id IS NOT NULL AND NOT public.checker_post_submit_receipt_valid(r,'execute',false)) + OR (r.finalize_evidence_id IS NOT NULL AND NOT public.checker_post_submit_receipt_valid(r,'finalize',false)) + ) THEN + RAISE EXCEPTION 'retained checker authorization receipts are unprovable' USING ERRCODE='23514'; + END IF; + END $$ + """) + for phase in ("execute", "finalize"): + op.create_foreign_key( + f"fk_checker_runs_{phase}_evidence", "checker_runs", "audit_events", + [f"{phase}_evidence_id"], ["id"], source_schema="public", referent_schema="public", + deferrable=True, initially="DEFERRED", + ) + op.execute(""" + CREATE CONSTRAINT TRIGGER checker_post_submit_receipt_custody + AFTER INSERT OR UPDATE ON public.checker_runs + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW + EXECUTE FUNCTION public.guard_checker_post_submit_receipts() + """) + + +def _functions(): + op.execute(""" + CREATE FUNCTION public.checker_post_submit_authority_digest(r public.checker_runs, phase text) + RETURNS text LANGUAGE sql IMMUTABLE STRICT SET search_path = pg_catalog, public, pg_temp AS $$ + SELECT CASE WHEN $2 IN ('execute','finalize') THEN + 'sha256:' || encode(sha256(convert_to(public.project_guide_projection_canonical_json( + jsonb_build_object( + 'domain','workstream.authorization.checker_post_submit', 'phase',$2, + 'action_id','checker.post_submit.' || $2, 'permission_id','checker.post_submit.' || $2, + 'service_identity','workstream.checker.post_submit', + 'resource_type','checker_run', 'resource_id',r.id::text, 'scope_project_id',r.project_id::text, + 'facts',jsonb_build_object( + 'project_id',r.project_id::text, 'task_id',r.task_id::text, + 'submission_id',r.submission_id::text, 'submission_version',r.submission_version, + 'request_id',r.evaluation_request_id::text, 'request_digest',r.request_digest, + 'evaluation_generation',r.evaluation_generation, + 'attempt_id',r.id::text, 'result_id',r.result_id::text, + 'lease_id',r.worker_lease_id::text, 'lease_generation',r.worker_lease_generation, + 'lease_expires_at',public.outbox_dispatch_utc(r.worker_lease_expires_at) + ) || CASE WHEN $2='finalize' THEN jsonb_build_object( + 'execute_evidence_id',r.execute_evidence_id::text, + 'result_digest',r.result_digest, 'outcome',r.status, 'failure_code',r.failure_code, + 'material',r.material_custody::jsonb, 'output_binding_ids','[]'::jsonb + ) ELSE '{}'::jsonb END + )), 'UTF8')), 'hex') ELSE NULL END + $$ + """) + op.execute(""" + CREATE FUNCTION public.checker_post_submit_receipt_valid(r public.checker_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 $2='execute' THEN r.execute_evidence_id ELSE r.finalize_evidence_id END + AND $2 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.post_submit' + AND l.subject_kind='service' + AND (NOT $3 OR (p.status='active' AND l.status='active')) + AND a.action_id='checker.post_submit.' || $2 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='checker_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.evaluation_request_id AND a.correlation_id=r.evaluation_request_id + AND a.after_facts::jsonb=jsonb_build_object( + 'allowed',true,'resource_context_digest',public.checker_post_submit_authority_digest(r,$2) + ) + AND ($2='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(""" + CREATE FUNCTION public.guard_checker_post_submit_receipts() RETURNS trigger + LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ + DECLARE current_run public.checker_runs%ROWTYPE; new_execute boolean; new_finalize boolean; + BEGIN + SELECT r.* INTO current_run FROM public.checker_runs r WHERE r.id=NEW.id; + IF NOT FOUND THEN + RAISE EXCEPTION 'checker authorization receipt 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.checker_post_submit_receipt_valid(current_run,'execute',new_execute)) + OR (current_run.finalize_evidence_id IS NOT NULL AND NOT + public.checker_post_submit_receipt_valid(current_run,'finalize',new_finalize)) THEN + RAISE EXCEPTION 'checker authorization receipt custody mismatch' USING ERRCODE='23514'; + END IF; + RETURN NULL; + END $$ + """) + + +def downgrade(): + 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 34ac46974..f6e94785a 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -508,15 +508,16 @@ def authority(session): def post_submission_materialization(*, sessions, store, namespace, preparation, inspector) -> PostSubmissionMaterializationPort: - """Compose hidden verified input; production authority remains explicitly unavailable.""" + """Compose exact input with fresh materializer authority and CHECKERS lease custody.""" from app.adapters.tasks import submitted_bundle_port - from app.modules.artifacts.post_submit_materialization import ( - DenyPostSubmissionMaterializationAuthority, PostSubmissionMaterializer, - ) + from app.modules.artifacts.post_submit_materialization import PostSubmissionMaterializer + from app.adapters.auth import post_submit_materialization_authority + from app.adapters.checkers import current_post_submit_execution return PostSubmissionMaterializer( sessions=sessions, tasks=submitted_bundle_port, store=store, namespace=namespace, preparation=preparation, inspector=inspector, - authority=DenyPostSubmissionMaterializationAuthority(), + authority=post_submit_materialization_authority, + current_execution=current_post_submit_execution, ) diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index 016670243..789ab3b3d 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -234,3 +234,15 @@ def history_read_authorization(kernel): """Compose current AUTH decisions for owner-selected immutable history.""" from app.modules.authorization.history_authorization import HistoryReadAuthorization return HistoryReadAuthorization(kernel) + + +def post_submit_execution_authority(session): + """Compose fixed checker execute/finalize authority over shared PREP.""" + from app.modules.authorization.post_submit_authorization import PostSubmitExecutionAuthorization + return PostSubmitExecutionAuthorization(session) + + +def post_submit_materialization_authority(session): + """Compose the separate fixed ART materializer principal.""" + from app.modules.authorization.post_submit_authorization import PostSubmitMaterializationAuthorization + return PostSubmitMaterializationAuthorization(session) diff --git a/backend/app/adapters/checkers/__init__.py b/backend/app/adapters/checkers/__init__.py index caaefaf26..7fe5cb4c1 100644 --- a/backend/app/adapters/checkers/__init__.py +++ b/backend/app/adapters/checkers/__init__.py @@ -116,14 +116,14 @@ def checker_history_repository(session) -> CheckerHistoryReadPort: def post_submission_executor(*, sessions, materialization): - """Compose the sole executor with action-specific production denial.""" + """Compose the sole executor with exact fixed-service AUTH at both phases.""" from app.adapters.outbox import outbox_append from app.modules.checkers.execution import PostSubmissionExecutor - from app.modules.checkers.execution_authority import DenyExecutionAuthority, DenyFinalizationAuthority + from app.adapters.auth import post_submit_execution_authority from app.modules.checkers.runner import default_checker_registry return PostSubmissionExecutor(sessions=sessions, materialization=materialization, - execute_authority=lambda session: DenyExecutionAuthority(), - finalize_authority=lambda session: DenyFinalizationAuthority(), + execute_authority=post_submit_execution_authority, + finalize_authority=post_submit_execution_authority, registry=default_checker_registry(), outbox=outbox_append) @@ -131,3 +131,9 @@ def checker_output_reservations(session): """Resolve exact active run/lease custody; current catalogue has zero slots.""" 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) diff --git a/backend/app/modules/actors/api/service_identities.py b/backend/app/modules/actors/api/service_identities.py index 1b4a40b27..9175618dd 100644 --- a/backend/app/modules/actors/api/service_identities.py +++ b/backend/app/modules/actors/api/service_identities.py @@ -15,6 +15,7 @@ class ServiceIdentity(StrEnum): ARTIFACT_MATERIALIZER = "workstream.artifact.materializer" ARTIFACT_CHECKER_OUTPUT = "workstream.artifact.checker_output" TASK_ASSIGNMENT_RECONCILER = "workstream.task.assignment_reconciler" + CHECKER_POST_SUBMIT = "workstream.checker.post_submit" OUTBOX_DISPATCHER = "workstream.outbox.dispatcher" PROJECT_SETUP = "workstream.project.setup" REVIEW_PREFERENCE_EXPIRY = "workstream.review.preference_expiry" diff --git a/backend/app/modules/artifacts/post_submit_materialization.py b/backend/app/modules/artifacts/post_submit_materialization.py index 0d6a3fda0..9274ef723 100644 --- a/backend/app/modules/artifacts/post_submit_materialization.py +++ b/backend/app/modules/artifacts/post_submit_materialization.py @@ -2,7 +2,6 @@ import asyncio from collections.abc import Callable -from typing import Protocol from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -14,10 +13,10 @@ from app.modules.checkers.api.materialization import ( PostSubmissionMaterialConsumer, PostSubmissionMaterializationResult, PostSubmissionMaterializationUnavailable, PostSubmissionMaterializationFailure, - SubmissionMaterialEntry, + SubmissionMaterialEntry, MaterializationFacts, MaterializationAuthorityPort, PreparedMaterialization, ) from app.modules.artifacts.post_submit_selection import ( - PostSubmissionMaterialSelection, select_post_submission_material, + select_post_submission_material, ) from app.modules.artifacts.preparation import ArtifactPreparationService, ArtifactScratchIntegrityError from app.modules.artifacts.service import ArtifactStorageNamespaceError, ArtifactStorageNamespaceSpec @@ -26,35 +25,13 @@ SubmissionArchiveRejectedError, ) from app.modules.artifacts.submission_manifest import build_submission_manifest -from app.modules.checkers.api import PostSubmissionEvaluationRequest, PostSubmissionEvaluationResult +from app.modules.checkers.api import PostSubmissionEvaluationResult +from app.modules.checkers.api.execution import ( + ExecuteFacts, CurrentExecutionPort, VerifiedMaterialFacts, CheckerExecutionUnavailable, +) from app.modules.tasks.api.submitted_bundle import SubmittedBundlePort, SubmittedBundleUnavailable -class PostSubmissionMaterializationAuthority(Protocol): - """Separate early service availability from exact resolved-material authority.""" - async def preflight(self, request: PostSubmissionEvaluationRequest) -> None: - """Deny unavailable fixed-service access before protected reads.""" - - async def authorize( - self, request: PostSubmissionEvaluationRequest, selection: PostSubmissionMaterialSelection, - ) -> None: - """Authorize exact resolved facts before provider and scratch access.""" - - -class DenyPostSubmissionMaterializationAuthority: - """Production remains unavailable until ARCH-04D activates exact service authority.""" - - async def preflight(self, request: PostSubmissionEvaluationRequest) -> None: - """Reject production access before any protected selection or byte read.""" - raise PostSubmissionMaterializationUnavailable("post_submit_materialization_unavailable") - - async def authorize( - self, request: PostSubmissionEvaluationRequest, selection: PostSubmissionMaterialSelection, - ) -> None: - """Reject exact material access until live authority is implemented.""" - raise PostSubmissionMaterializationUnavailable("post_submit_materialization_unavailable") - - class _MaterialView: """Revoke callback access independently of the private projection lifetime.""" def __init__(self, tree: SealedSubmissionTree) -> None: @@ -124,7 +101,12 @@ async def process(self, reader, workspace) -> PostSubmissionEvaluationResult: finally: if self._view is not None: self._view.close() - await await_cancellation_resistant(asyncio.to_thread(projection.__exit__, None, None, None)) + try: + await await_cancellation_resistant(asyncio.to_thread(projection.__exit__, None, None, None)) + except ArtifactScratchIntegrityError: + raise + except Exception: + raise ArtifactScratchIntegrityError("post_submit_projection_cleanup_unconfirmed") from None class PostSubmissionMaterializer: @@ -134,64 +116,116 @@ def __init__( self, *, sessions: async_sessionmaker[AsyncSession], tasks: Callable[[AsyncSession], SubmittedBundlePort], store: ArtifactStore, namespace: ArtifactStorageNamespaceSpec, preparation: ArtifactPreparationService, - inspector: SubmissionArchiveInspector, authority: PostSubmissionMaterializationAuthority, + inspector: SubmissionArchiveInspector, + authority: Callable[[AsyncSession], MaterializationAuthorityPort], + current_execution: Callable[[AsyncSession], CurrentExecutionPort], ) -> None: """Require explicit owner ports, scratch service and material authority.""" self._sessions, self._tasks, self._store = sessions, tasks, store self._namespace, self._preparation = namespace, preparation self._inspector, self._authority = inspector, authority + self._current_execution = current_execution + + @staticmethod + def _authority_facts(execution, selected): + """Project exact selected ancestry without exposing ART provider coordinates.""" + submission = selected.submission + return MaterializationFacts( + execution=execution, + material=VerifiedMaterialFacts( + submission_id=submission.submission_id, submission_version=submission.submission_version, + admission_id=submission.admission_id, binding_id=submission.binding_id, + content_id=submission.content_id, replica_id=selected.replica_id, + content_sha256=selected.sha256, byte_count=selected.byte_count, + semantic_manifest_sha256=selected.semantic_manifest_sha256, + ), + evidence_id=selected.evidence_id, verification_receipt_id=selected.verification_receipt_id, + verification_job_id=selected.verification_job_id, + verification_generation=selected.verification_generation, + namespace_fingerprint=selected.namespace_fingerprint, + semantic_manifest_id=selected.semantic_manifest_id, + ) - async def _select(self, request) -> PostSubmissionMaterialSelection: - """Detach fresh selection facts and close the transaction before I/O.""" + async def _select(self, facts, *, original=None, evidence_id=None): + """AUTH -> current CHECKERS lease -> ART selection, all closed before I/O.""" try: async with self._sessions() as session, session.begin(): - return await select_post_submission_material( - session, tasks=self._tasks(session), request=request, - namespace=self._namespace, store=self._store, - ) - except (SubmittedBundleUnavailable, ArtifactStorageNamespaceError): + async with self._authority(session).prepare_materialization(facts) as prepared: + if not isinstance(prepared, PreparedMaterialization): + raise PostSubmissionMaterializationUnavailable("post_submit_materialization_unavailable") + await self._current_execution(session).require_current_execution(facts) + selected = await select_post_submission_material( + session, tasks=self._tasks(session), request=facts.request, + namespace=self._namespace, store=self._store, + ) + authority_facts = self._authority_facts(facts, selected) + if original is None: + evidence_id = await prepared.consume(authority_facts) + else: + if selected != original: + raise PostSubmissionMaterializationUnavailable("post_submit_material_changed") + await prepared.validate_replay(authority_facts, evidence_id) + return selected, evidence_id + except (SubmittedBundleUnavailable, ArtifactStorageNamespaceError, CheckerExecutionUnavailable): raise PostSubmissionMaterializationUnavailable("post_submit_material_unavailable") from None async def materialize( - self, request: PostSubmissionEvaluationRequest, consumer: PostSubmissionMaterialConsumer, + self, facts: ExecuteFacts, consumer: PostSubmissionMaterialConsumer, ) -> PostSubmissionMaterializationResult: """Verify bytes, run the scoped consumer, clean up, and reject late drift.""" - request = PostSubmissionEvaluationRequest.model_validate(request) - await self._authority.preflight(request) - selected = await self._select(request) - await self._authority.authorize(request, selected) + facts = ExecuteFacts.model_validate_json(facts.model_dump_json()) + request = facts.request + selected, evidence_id = await self._select(facts) prepared = None + failure = None try: - prepared = await self._preparation.prepare( - self._store.open(selected.provider_object_ref), media_type=selected.media_type, - expected_sha256=selected.sha256, expected_size=selected.byte_count, - ) - 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: - raise PostSubmissionMaterializationFailure("post_submit_material_manifest_mismatch") - 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, + try: + prepared = await self._preparation.prepare( + self._store.open(selected.provider_object_ref), media_type=selected.media_type, + expected_sha256=selected.sha256, expected_size=selected.byte_count, + ) + 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: + raise PostSubmissionMaterializationFailure("post_submit_material_manifest_mismatch") + 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, + ) + finally: + if prepared is not None: + try: + await prepared.close() + except ArtifactScratchIntegrityError: + raise + except Exception: + raise ArtifactScratchIntegrityError("post_submit_source_cleanup_unconfirmed") from None + except asyncio.CancelledError: + # The executor may convert its deadline cancellation into a terminal + # timeout. Validate the completed read before allowing that conversion. + # Denial/drift must win over cancellation; cleanup has already finished. + await await_cancellation_resistant( + self._select(facts, original=selected, evidence_id=evidence_id) ) + raise except ArtifactScratchIntegrityError: # Unconfirmed cleanup must never become a retained terminal result. raise + except PostSubmissionMaterializationFailure as error: + failure = error except ( ArtifactObjectMissingError, ArtifactInputMismatchError, ArtifactIntegrityError, ArtifactLimitExceededError, ArtifactStoreUnavailableError, SubmissionArchiveRejectedError, ): - raise PostSubmissionMaterializationFailure("post_submit_material_unavailable") from None - finally: - if prepared is not None: - await prepared.close() - if await self._select(request) != selected: - raise PostSubmissionMaterializationUnavailable("post_submit_material_changed") + failure = PostSubmissionMaterializationFailure("post_submit_material_unavailable") + await self._select(facts, original=selected, evidence_id=evidence_id) + if failure is not None: + raise failure facts = selected.submission return PostSubmissionMaterializationResult( submission_id=facts.submission_id, submission_version=facts.submission_version, diff --git a/backend/app/modules/artifacts/preparation.py b/backend/app/modules/artifacts/preparation.py index 620380b89..5b055e6cb 100644 --- a/backend/app/modules/artifacts/preparation.py +++ b/backend/app/modules/artifacts/preparation.py @@ -17,7 +17,7 @@ import stat as stat_module import threading import time -from typing import Any, BinaryIO, Protocol, TypeVar +from typing import Any, BinaryIO, Protocol, TypeVar, cast from app.core.identifiers import new_record_id from app.interfaces.artifacts import ( @@ -1698,26 +1698,45 @@ async def _process_prepared_submission( if active is None or not active.handle_issued or active.stream_claimed: raise ArtifactScratchIntegrityError("prepared artifact source is unavailable") - async def process_and_cleanup() -> _InspectionResult: - with self._manager.extraction_workspace( - reserved_bytes=reserved_bytes, - maximum_entries=maximum_entries, - ) as workspace: - active.reader.seek(0) - try: - return await processor.process(active.reader, workspace) - finally: + async def process_and_cleanup() -> tuple[_InspectionResult | None, BaseException | None]: + workspace_entered = False + try: + with self._manager.extraction_workspace( + reserved_bytes=reserved_bytes, + maximum_entries=maximum_entries, + ) as workspace: + workspace_entered = True active.reader.seek(0) + try: + # Keep callback failure distinct from the owner's cleanup. + # A cancelled caller drains cleanup, not the aborted result. + try: + return await processor.process(active.reader, workspace), None + except ArtifactScratchIntegrityError: + raise + except BaseException as error: + return None, error + finally: + active.reader.seek(0) + except ArtifactScratchIntegrityError: + raise + except Exception: + if not workspace_entered: + raise + raise ArtifactScratchIntegrityError("submission_workspace_cleanup_unconfirmed") from None operation = asyncio.create_task(process_and_cleanup()) try: async with asyncio.timeout_at(active.deadline): - return await asyncio.shield(operation) + result, error = await asyncio.shield(operation) + if error is not None: + raise error + return cast(_InspectionResult, result) except TimeoutError: processor.abort() try: await await_cancellation_resistant(operation) - except BaseException: + except asyncio.CancelledError: pass raise ArtifactPreparationDeadlineError( "artifact preparation deadline exceeded" @@ -1726,7 +1745,7 @@ async def process_and_cleanup() -> _InspectionResult: processor.abort() try: await await_cancellation_resistant(operation) - except BaseException: + except asyncio.CancelledError: pass raise cancellation from None diff --git a/backend/app/modules/artifacts/sources.py b/backend/app/modules/artifacts/sources.py index 7367cf003..f77c0686c 100644 --- a/backend/app/modules/artifacts/sources.py +++ b/backend/app/modules/artifacts/sources.py @@ -9,7 +9,7 @@ from uuid import UUID from app.core.identifiers import new_record_id -from app.core.cancellation import await_completion_preserving_cancellation +from app.core.cancellation import await_cancellation_resistant _COMMITTED_SOURCE_SEAL = object() @@ -212,10 +212,12 @@ async def close(self) -> None: return cleanup = asyncio.create_task(self._owner.release_prepared_artifact(self._binding)) try: - await await_completion_preserving_cancellation(cleanup) + await asyncio.shield(cleanup) except asyncio.CancelledError: - if cleanup.done() and not cleanup.cancelled() and cleanup.exception() is None: - self._closed = True + # Failed cleanup must remain observable; cancellation is not proof + # that scratch ownership was released. + await await_cancellation_resistant(cleanup) + self._closed = True raise else: self._closed = True diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 2fb73c9bb..2c479f2c5 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -34,7 +34,7 @@ } ) _RESOURCE_TYPES = frozenset( - """outbox_event actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task + """checker_run outbox_event actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task submission 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 afa6b9ab4..9048b4c78 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[75] + total: Literal[77] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 2e08b4ec8..92947aec9 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -23,6 +23,8 @@ class PermissionId(StrEnum): ACTOR_IDENTITY_LINK_REVOKE = "actor.identity_link.revoke" ACTOR_IDENTITY_LINK_REACTIVATE = "actor.identity_link.reactivate" TASK_ASSIGNMENT_AUTHORITY_RECONCILE = "task.assignment.authority_reconcile" + CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" + CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" ADMIN_ROLE_READ = "admin_role.read" @@ -138,6 +140,8 @@ class ActionId(StrEnum): ACTOR_IDENTITY_LINK_REVOKE = "actor.identity_link.revoke" ACTOR_IDENTITY_LINK_REACTIVATE = "actor.identity_link.reactivate" TASK_ASSIGNMENT_AUTHORITY_RECONCILE = "task.assignment.authority_reconcile" + CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" + CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" PROJECT_CONTRIBUTOR_CANDIDATE_LIST = "project.contributor_candidate.list" @@ -303,6 +307,7 @@ class ActionOwner(StrEnum): ARCH_03C5 = "WS-ARCH-001-03C5" ARCH_03C6 = "WS-ARCH-001-03C6" ARCH_03C7 = "WS-ARCH-001-03C7" + ARCH_04D2 = "WS-ARCH-001-04D2" ARCH_03C1 = "WS-ARCH-001-03C1" AUTH_OUTBOX_01 = "WS-AUTH-001-OUTBOX-01" AUTH_14 = "WS-AUTH-001-14" @@ -640,12 +645,14 @@ def _active( PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionOwner.AUTH_ART_02D_INTERNAL, ), + _active(ActionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), + _active(ActionId.CHECKER_POST_SUBMIT_FINALIZE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), _active( ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.XINT_002_06A, ), - _planned( + _active( ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.AUTH_ART_06A, @@ -701,6 +708,8 @@ def _active( ) NEW_PERMISSION_IDS = frozenset( { + PermissionId.CHECKER_POST_SUBMIT_EXECUTE, + PermissionId.CHECKER_POST_SUBMIT_FINALIZE, PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, PermissionId.OUTBOX_DISPATCH, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, @@ -734,9 +743,9 @@ def _active( def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 75 or len(ACTION_IDS) != 140: + if len(PERMISSION_IDS) != 77 or len(ACTION_IDS) != 142: raise RuntimeError("authorization catalogue count mismatch") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 26: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 28: raise RuntimeError("authorization permission boundary mismatch") @@ -855,6 +864,9 @@ def _index_actions( ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, ActionId.COMPENSATION_ADAPTER_BINDING_READ, ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, @@ -882,6 +894,7 @@ def _index_actions( _SERVICE_ACTIONS = { + 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}), ServiceIdentity.ARTIFACT_VERIFIER: frozenset({ActionId.ARTIFACT_VERIFICATION_EXECUTE}), @@ -932,6 +945,8 @@ def _index_actions( _EXPECTED_SERVICE_ACTION_MEMBERSHIPS = frozenset( (identity, action) for identity, action in ( + (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), (ServiceIdentity.OUTBOX_DISPATCHER, ActionId.OUTBOX_DISPATCH), (ServiceIdentity.ARTIFACT_VERIFIER, ActionId.ARTIFACT_VERIFICATION_EXECUTE), @@ -974,6 +989,9 @@ def _index_actions( _ACTIVE_SERVICE_ACTIONS = { + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionId.OUTBOX_DISPATCH, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, @@ -995,6 +1013,8 @@ def _index_service_actions( rows: dict[ServiceIdentity, frozenset[ActionId]], ) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: expected_metadata = { + ActionId.CHECKER_POST_SUBMIT_EXECUTE: (PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), + ActionId.CHECKER_POST_SUBMIT_FINALIZE: (PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: (PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionOwner.ARCH_03C1), ActionId.OUTBOX_DISPATCH: (PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), ActionId.ARTIFACT_VERIFICATION_EXECUTE: (PermissionId.ARTIFACT_VERIFICATION_EXECUTE, ActionOwner.AUTH_ART_02D_INTERNAL), diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index 418450836..32f2109c2 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -13,6 +13,7 @@ CONTEXT_DIGEST_RESOURCE_TYPES = ( "outbox_event", + "checker_run", "task_authority", "task_submission_history", "submission_history", @@ -41,6 +42,7 @@ AuthorizationDecisionResourceType = Literal[ "outbox_event", + "checker_run", "task_authority", "task_submission_history", "submission_history", diff --git a/backend/app/modules/authorization/domain/audit_targets.py b/backend/app/modules/authorization/domain/audit_targets.py index c437dc8de..8b7b1b595 100644 --- a/backend/app/modules/authorization/domain/audit_targets.py +++ b/backend/app/modules/authorization/domain/audit_targets.py @@ -1,6 +1,7 @@ """Exact project resource audit selectors, without raw product facts.""" from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.post_submit import PostSubmitResourceContext 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 @@ -76,7 +77,7 @@ def project_authority_audit_target( AdapterBindingMutationResourceContext, ProjectGuideProjectionResourceContext, ProjectSetupFinalizationResourceContext, - GuideProposalResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, + GuideProposalResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, PostSubmitResourceContext, ), ): project_id = str(getattr(resource, "project_id", None) or resource.scope_project_id) diff --git a/backend/app/modules/authorization/domain/post_submit.py b/backend/app/modules/authorization/domain/post_submit.py new file mode 100644 index 000000000..5e680b3fa --- /dev/null +++ b/backend/app/modules/authorization/domain/post_submit.py @@ -0,0 +1,107 @@ +"""Exact fixed-service post-submit resources over CHECKERS' public phase contracts.""" + +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.execution import ( + ExecuteFacts, FinalizeAuthorityFacts, execution_authority_digest, +) +from app.modules.checkers.api.materialization import MaterializationFacts, materialization_authority_digest + +EXECUTE = ActionId.CHECKER_POST_SUBMIT_EXECUTE +FINALIZE = ActionId.CHECKER_POST_SUBMIT_FINALIZE +MATERIALIZE = ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE +POST_SUBMIT_ACTIONS = frozenset({EXECUTE, FINALIZE, MATERIALIZE}) +_TYPES = {EXECUTE: ExecuteFacts, FINALIZE: FinalizeAuthorityFacts, MATERIALIZE: MaterializationFacts} + + +def post_submit_request(facts): + """Project the exact CHECKERS request from the closed phase facts.""" + return facts.execution.request if type(facts) is MaterializationFacts else facts.request + + +def post_submit_digest(facts): + """Delegate canonical phase commitments to the consumer-owned public contract.""" + if type(facts) is MaterializationFacts: + return materialization_authority_digest(facts) + return execution_authority_digest(facts) + + +class PostSubmitResourceContext(BaseModel): + """A single phase and exact project/attempt, never a generic artifact permission.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + resource_type: Literal["checker_run"] = "checker_run" + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + facts: ExecuteFacts | FinalizeAuthorityFacts | MaterializationFacts + + @model_validator(mode="after") + def validate_identity(self): + expected = _TYPES.get(self.action_id) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid post-submit authority phase") + checked = expected.model_validate_json(self.facts.model_dump_json()) + request = post_submit_request(checked) + execution = checked.execution if type(checked) is MaterializationFacts else checked + if self.resource_id != execution.lease.reservation.attempt_id or self.scope_project_id != request.project_id: + raise ValueError("invalid post-submit authority resource") + post_submit_digest(checked) + return self + + +def post_submit_resource(action, facts): + """Build a typed owner-verified resource; no provider coordinates enter AUTH.""" + execution = facts.execution if type(facts) is MaterializationFacts else facts + return PostSubmitResourceContext( + action_id=action, resource_id=execution.lease.reservation.attempt_id, + scope_project_id=execution.request.project_id, facts=facts, + ) + + +def post_submit_prepare_values(action, request, execution=None): + """Bind immutable request selectors; materialization additionally binds its existing lease.""" + values = { + "project_id": str(request.project_id), "request_id": str(request.evaluation_request_id), + "request_digest": request.request_sha256, + } + if action == MATERIALIZE: + values["execution_digest"] = execution_authority_digest(execution) + return values + + +def parse_post_submit_prepare(action, values, invalid_error): + """Reject malformed or extra prepare selectors before locking product rows.""" + if action not in POST_SUBMIT_ACTIONS: + return None + import re + expected = {"project_id", "request_id", "request_digest"} + if action == MATERIALIZE: + expected.add("execution_digest") + try: + if set(values) != expected: + raise ValueError("invalid keys") + for key in ("project_id", "request_id"): + if type(values[key]) is not str or str(UUID(values[key])) != values[key]: + raise ValueError("invalid identity") + for key in expected - {"project_id", "request_id"}: + if type(values[key]) is not str or re.fullmatch(r"sha256:[0-9a-f]{64}", values[key]) is None: + raise ValueError("invalid digest") + except (ValueError, TypeError, KeyError) as error: + raise invalid_error("invalid prepared post-submit authority") from error + return dict(values) + + +def post_submit_prepare_matches(action, prepared, resource): + """Match final facts to the exact preparation and forbid phase substitution.""" + if type(resource) is not PostSubmitResourceContext or resource.action_id is not action: + return False + resource.validate_identity() + facts = resource.facts + return prepared == post_submit_prepare_values( + action, post_submit_request(facts), facts.execution if type(facts) is MaterializationFacts else None, + ) diff --git a/backend/app/modules/authorization/domain/prepared_service.py b/backend/app/modules/authorization/domain/prepared_service.py index 4aa468575..0fc2a4c0c 100644 --- a/backend/app/modules/authorization/domain/prepared_service.py +++ b/backend/app/modules/authorization/domain/prepared_service.py @@ -6,6 +6,9 @@ from app.core.hashing import canonical_json_hash from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.post_submit import ( + POST_SUBMIT_ACTIONS, PostSubmitResourceContext, parse_post_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 @@ -102,7 +105,10 @@ def fixed_service_scope_project(action_id, scope, artifact_resource): """Admit exact setup, dispatcher or artifact scopes without conflating owners.""" outbox = (action_id in {ActionId.OUTBOX_DISPATCH, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE} and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None) - if is_project_setup_scope(action_id, scope) or outbox: + if is_project_setup_scope(action_id, scope) or outbox or ( + action_id in POST_SUBMIT_ACTIONS and scope.kind is PreparedAuthorityScopeKind.PROJECT + and scope.project_id is not None + ): return scope.project_id if (artifact_resource is None or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL or scope.artifact_resource_type != artifact_resource[0]): @@ -113,6 +119,9 @@ def fixed_service_scope_project(action_id, scope, artifact_resource): def fixed_service_resource_matches(action_id, resource, project_id, artifact_type, artifact_id, expected): """Check the final resource against the exact prepared service scope.""" + if action_id 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 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: @@ -132,6 +141,7 @@ def prepared_request_digest(value): def prepared_fixed_service_bindings(action, request, invalid_error): """Keep exact dispatcher and assignment effect commitments with service guards.""" return { + "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 815805a70..2fae606e3 100644 --- a/backend/app/modules/authorization/domain/resource_digest.py +++ b/backend/app/modules/authorization/domain/resource_digest.py @@ -1,6 +1,7 @@ """Canonical exact-resource digests shared by kernel decisions and prepared bindings.""" from pydantic import BaseModel +from app.modules.authorization.domain.post_submit import PostSubmitResourceContext, post_submit_digest 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 @@ -23,6 +24,9 @@ def authorization_resource_digest(resource: BaseModel) -> str: """Preserve purpose-specific public digest parity and canonical fallback custody.""" + if type(resource) is PostSubmitResourceContext: + resource.validate_identity() + return post_submit_digest(resource.facts) 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 new file mode 100644 index 000000000..bca98ef86 --- /dev/null +++ b/backend/app/modules/authorization/post_submit_authorization.py @@ -0,0 +1,124 @@ +"""Fixed post-submit principals through canonical AUTH/PREP and exact consumer contracts.""" + +from contextlib import asynccontextmanager, contextmanager + +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.domain.post_submit import ( + EXECUTE, FINALIZE, MATERIALIZE, post_submit_digest, post_submit_prepare_values, + post_submit_request, post_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.execution import ( + CheckerExecutionUnavailable, ExecuteEvidence, FinalizeEvidence, + PreparedExecution, PreparedFinalization, +) +from app.modules.checkers.api.materialization import ( + PreparedMaterialization, PostSubmissionMaterializationUnavailable, +) +from app.modules.checkers.api.post_submit import PostSubmissionEvaluationRequest + + +@contextmanager +def _authority_errors(action): + """Conceal AUTH failures without swallowing protected-owner errors.""" + try: + yield + except (AuthorizationDenied, AuthorizationEvidenceUnavailable, PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported) as error: + exception = PostSubmissionMaterializationUnavailable if action == MATERIALIZE else CheckerExecutionUnavailable + raise exception("post_submit_authority_unavailable") from error + + +class _PreparedPhase: + """Only shared PREP owns handle lifetime and one-use/transaction enforcement.""" + + def __init__(self, service, handle, caller_input, action): + self._service, self._handle, self._input, self._action = service, handle, caller_input, action + + async def _consume(self, facts): + with _authority_errors(self._action): + return await self._service.consume( + self._handle, self._action, self._input, post_submit_resource(self._action, facts), + ) + + async def validate_replay(self, facts, evidence_id): + with _authority_errors(self._action): + await self._service.validate_replay( + self._handle, self._action, self._input, + post_submit_resource(self._action, facts), evidence_id, + ) + + +class _PreparedExecute(_PreparedPhase, PreparedExecution): + async def consume(self, facts): + decision = await self._consume(facts) + return ExecuteEvidence(evidence_id=decision.decision_id, facts_digest=post_submit_digest(facts)) + + +class _PreparedFinalize(_PreparedPhase, PreparedFinalization): + async def consume(self, facts): + decision = await self._consume(facts) + return FinalizeEvidence(evidence_id=decision.decision_id, facts_digest=post_submit_digest(facts)) + + +class _PreparedMaterialize(_PreparedPhase, PreparedMaterialization): + async def consume(self, facts): + return (await self._consume(facts)).decision_id + + +@asynccontextmanager +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()) + prepare_values = post_submit_prepare_values(action, request, execution) + except (AttributeError, ValueError, TypeError) as error: + exception = PostSubmissionMaterializationUnavailable if action == MATERIALIZE else CheckerExecutionUnavailable + raise exception("post_submit_authority_unavailable") from error + identity = ServiceIdentity.ARTIFACT_MATERIALIZER if action == MATERIALIZE else ServiceIdentity.CHECKER_POST_SUBMIT + prepared_type = {EXECUTE: _PreparedExecute, FINALIZE: _PreparedFinalize, MATERIALIZE: _PreparedMaterialize}[action] + with _authority_errors(action): + async with fixed_service_prepared_authorization( + session, service_identity=identity, request_id=request.evaluation_request_id, + correlation_id=request.evaluation_request_id, + ) as authority: + caller_input = PreparedAuthorizationInput( + idempotency_key=request.evaluation_request_id, + request_value=prepare_values, + ) + handle = await authority.service.prepare(action, caller_input, PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=request.project_id, + )) + yield prepared_type(authority.service, handle, caller_input, action) + + +class PostSubmitExecutionAuthorization: + """Expose the two exact checker operations without selectable service identity.""" + + def __init__(self, session): + self._session = 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()) + + def prepare_execution(self, request): + return _prepare(self._session, EXECUTE, request) + + def prepare_finalization(self, request): + return _prepare(self._session, FINALIZE, request) + + +class PostSubmitMaterializationAuthorization: + """Give the fixed materializer only exact current submitted-byte read authority.""" + + def __init__(self, session): + self._session = session + + def prepare_materialization(self, facts): + return _prepare(self._session, MATERIALIZE, post_submit_request(facts), execution=facts) diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index c45f167a3..2827fa330 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -117,6 +117,8 @@ from app.modules.authorization.domain.task_authority import ( TASK_ACTIONS, TaskAuthorityResourceContext, parse_task_authority_binding, ) +from app.modules.authorization.domain.post_submit import POST_SUBMIT_ACTIONS, PostSubmitResourceContext, post_submit_prepare_matches +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_project_create_binding, @@ -183,6 +185,7 @@ class _PreparedAuthorizationBinding: request_digest: str assignment_invalidation_context: AssignmentInvalidationResourceContext | None = None outbox_dispatch_digest: str | None = None + post_submit_prepare_context: dict | None = None task_authority_context: TaskAuthorityResourceContext | None = None project_create_operation_id: UUID | None = None project_create_project_id: UUID | None = None @@ -531,6 +534,10 @@ def _validate_consumption(self, issuance, expected_action_id, caller_input, fina or issuance.binding.assignment_invalidation_context != final_resource_context ): raise PreparedAuthorizationHandleInvalid("invalid assignment reconciliation authority") + if expected_action_id in POST_SUBMIT_ACTIONS and not post_submit_prepare_matches( + expected_action_id, issuance.binding.post_submit_prepare_context, final_resource_context, + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared post-submit 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) @@ -639,7 +646,7 @@ async def validate_replay( issuance = self._live_issuance(handle) self._issued[handle] = _CONSUMED try: - replay = validate_review_replay if expected_action_id in GUIDE_PROPOSAL_ACTION_IDS | POST_POLICY_ACTION_IDS | {ActionId.PROJECT_GUIDE_ACTIVATE} else validate_projection_replay + replay = validate_post_submit_replay if expected_action_id in POST_SUBMIT_ACTIONS else validate_review_replay if expected_action_id in GUIDE_PROPOSAL_ACTION_IDS | POST_POLICY_ACTION_IDS | {ActionId.PROJECT_GUIDE_ACTIVATE} else validate_projection_replay await replay( self, issuance, @@ -915,7 +922,7 @@ def _scope_from_resource( artifact_resource_type=artifact_resource[0], artifact_resource_id=resource.resource_id, ) - if isinstance(resource, (ProjectGuideProjectionResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, AssignmentInvalidationResourceContext)): + if isinstance(resource, (ProjectGuideProjectionResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, AssignmentInvalidationResourceContext, PostSubmitResourceContext)): return PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, diff --git a/backend/app/modules/authorization/prepared_post_submit_replay.py b/backend/app/modules/authorization/prepared_post_submit_replay.py new file mode 100644 index 000000000..f7daf1f36 --- /dev/null +++ b/backend/app/modules/authorization/prepared_post_submit_replay.py @@ -0,0 +1,34 @@ +"""Validate retained phase receipts under the same fresh transaction-bound PREP.""" + +from app.modules.authorization.catalogue import ACTION_BY_ID +from app.modules.authorization.domain.post_submit import post_submit_digest, post_submit_request +from app.modules.authorization.domain.prepared_service import fixed_service_resource_matches +from app.modules.authorization.runtime import PreparedAuthorizationHandleInvalid, ServiceAuthorizationContext + + +async def validate_post_submit_replay(owner, issuance, action, caller_input, resource, decision_id): + """Require the exact original allow; never insert another audit event for replay.""" + owner._validate_consumption(issuance, action, caller_input, resource) + authority = issuance.authority + if ( + type(authority.context) is not ServiceAuthorizationContext + or authority.action_id is not action + or not fixed_service_resource_matches(action, resource, authority.scope_project_id, None, None, None) + ): + raise PreparedAuthorizationHandleInvalid("invalid post-submit replay authority") + event = await owner._authorization._audit.get_authority_event(decision_id) + request = post_submit_request(resource.facts) + expected = { + "id": str(decision_id), "event_domain": "authority", + "event_type": "SensitiveAuthorizationAllowed", "actor_ref_kind": "actor_profile", + "actor_id": str(authority.context.actor_profile_id), "action_id": action.value, + "permission_id": ACTION_BY_ID[action].permission_id.value, + "project_id": str(resource.scope_project_id), "resource_type": "checker_run", + "resource_id": str(resource.resource_id), "target_ref_kind": "project", + "target_ref_id": str(resource.scope_project_id), "denial_code": None, + "matched_grant_id": None, "request_id": str(request.evaluation_request_id), + "correlation_id": str(request.evaluation_request_id), + "after_facts": {"allowed": True, "resource_context_digest": post_submit_digest(resource.facts)}, + } + if event is None or any(getattr(event, key, object()) != value for key, value in expected.items()): + raise PreparedAuthorizationHandleInvalid("invalid retained post-submit receipt") diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index b1231544b..fed30e889 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -32,6 +32,7 @@ from app.modules.authorization.domain.post_policy import PostPolicyResourceContext from app.modules.authorization.domain.assignment_invalidation import AssignmentInvalidationResourceContext from app.modules.authorization.domain.outbox_dispatch import OutboxDispatchResourceContext +from app.modules.authorization.domain.post_submit import PostSubmitResourceContext from app.modules.authorization.domain.guide_activation import ProjectGuideActivationResourceContext from app.modules.authorization.service_actor_schemas import ServiceActorProvisionResourceContext from app.modules.authorization.catalogue import ActionId @@ -138,6 +139,9 @@ class PreparedAuthorityScope(BaseModel): @model_validator(mode="after") def validate_selector(self): """Require exactly the identifier owned by the selected scope kind.""" + artifact_selected = self.artifact_resource_type is not None or self.artifact_resource_id is not None + if self.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL and artifact_selected: + raise ValueError("invalid prepared authority scope") valid = ( ( self.kind is PreparedAuthorityScopeKind.ACTOR_SELF @@ -146,8 +150,6 @@ def validate_selector(self): and self.target_actor_profile_id is None and self.role is None and self.grant_id is None - and self.artifact_resource_type is None - and self.artifact_resource_id is None ) or ( self.kind is PreparedAuthorityScopeKind.SYSTEM @@ -156,8 +158,6 @@ def validate_selector(self): and self.target_actor_profile_id is None and self.role is None and self.grant_id is None - and self.artifact_resource_type is None - and self.artifact_resource_id is None ) or ( self.kind is PreparedAuthorityScopeKind.PROJECT @@ -165,8 +165,6 @@ def validate_selector(self): and self.project_id is not None and not (self.target_actor_profile_id is not None and self.grant_id is not None) and ((self.target_actor_profile_id is None) == (self.role is None)) - and self.artifact_resource_type is None - and self.artifact_resource_id is None ) or ( self.kind is PreparedAuthorityScopeKind.ARTIFACT_INTERNAL @@ -1207,6 +1205,7 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte | PostPolicyResourceContext | AssignmentInvalidationResourceContext | OutboxDispatchResourceContext + | PostSubmitResourceContext | ProjectSetupRunMutationResourceContext | ProjectGuideActivationResourceContext | ProjectGuideCompilationRequestResourceContext diff --git a/backend/app/modules/checkers/api/execution.py b/backend/app/modules/checkers/api/execution.py index 5d6b1e6bc..5697a05fe 100644 --- a/backend/app/modules/checkers/api/execution.py +++ b/backend/app/modules/checkers/api/execution.py @@ -2,10 +2,13 @@ from abc import ABC, abstractmethod from contextlib import AbstractAsyncContextManager +from datetime import UTC from typing import Literal, Protocol from pydantic import AwareDatetime, Field +from app.core.hashing import canonical_json_hash + from app.modules.checkers.api.post_submit import ( PostSubmissionEvaluationRequest, PostSubmissionEvaluationResult, @@ -77,6 +80,59 @@ class FinalizeFacts(ExecuteFacts): output_binding_ids: tuple[ResourceId, ...] = Field(max_length=0) +class FinalizeAuthorityFacts(FinalizeFacts): + """Final facts extended with the execute receipt read from the locked run.""" + + execute_evidence_id: ResourceId + + +def execution_authority_values(facts: ExecuteFacts | FinalizeAuthorityFacts) -> dict: + """Commit bounded phase facts; the request digest binds private policy/input text.""" + if type(facts) not in (ExecuteFacts, FinalizeAuthorityFacts): + raise ValueError("invalid checker authority facts") + facts = type(facts).model_validate_json(facts.model_dump_json()) + request, lease = facts.request, facts.lease + reservation = lease.reservation + if (reservation.request_id, reservation.request_digest, reservation.evaluation_generation) != ( + request.evaluation_request_id, request.request_sha256, request.evaluation_generation, + ): + raise ValueError("checker authority reservation mismatch") + phase = "finalize" if type(facts) is FinalizeAuthorityFacts else "execute" + action = "checker.post_submit." + phase + values = { + "project_id": str(request.project_id), "task_id": str(request.task_id), + "submission_id": str(request.submission_id), "submission_version": request.submission_version, + "request_id": str(request.evaluation_request_id), "request_digest": request.request_sha256, + "evaluation_generation": request.evaluation_generation, + "attempt_id": str(reservation.attempt_id), "result_id": str(reservation.result_id), + "lease_id": str(lease.lease_id), "lease_generation": lease.lease_generation, + "lease_expires_at": lease.expires_at.astimezone(UTC).isoformat(), + } + if phase == "finalize": + facts.result.validate_request(request) + if (facts.result.attempt_id, facts.result.result_id) != (reservation.attempt_id, reservation.result_id): + raise ValueError("checker authority result mismatch") + values.update( + execute_evidence_id=str(facts.execute_evidence_id), + result_digest=facts.result.result_digest, outcome=facts.result.outcome, + failure_code=facts.result.infrastructure_failure_code, + material=facts.material.model_dump(mode="json") if facts.material else None, + output_binding_ids=[], + ) + return { + "domain": "workstream.authorization.checker_post_submit", "phase": phase, + "action_id": action, "permission_id": action, + "service_identity": "workstream.checker.post_submit", + "resource_type": "checker_run", "resource_id": str(reservation.attempt_id), + "scope_project_id": str(request.project_id), "facts": values, + } + + +def execution_authority_digest(facts: ExecuteFacts | FinalizeAuthorityFacts) -> str: + """One canonical commitment shared by CHECKERS and AUTH, mirrored in PostgreSQL.""" + return canonical_json_hash(execution_authority_values(facts)) + + class ExecuteEvidence(PostSubmitValue): """Opaque action-specific receipt; real AUTH custody is installed by 04D.""" @@ -99,15 +155,33 @@ async def consume(self, facts: ExecuteFacts) -> ExecuteEvidence: """Consume execution facts and return their action-specific receipt.""" ... + @abstractmethod + async def validate_replay(self, facts: ExecuteFacts, evidence_id: ResourceId) -> None: + """Validate the stored execute receipt under fresh authority without another allow.""" + ... + class PreparedFinalization(ABC): """Separately prepared authority for terminal result publication.""" @abstractmethod - async def consume(self, facts: FinalizeFacts) -> FinalizeEvidence: + async def consume(self, facts: FinalizeAuthorityFacts) -> FinalizeEvidence: """Consume finalization facts and return their distinct receipt.""" ... + @abstractmethod + async def validate_replay(self, facts: FinalizeAuthorityFacts, evidence_id: ResourceId) -> None: + """Validate the exact original finalization receipt without another allow.""" + ... + + +class CurrentExecutionPort(Protocol): + """CHECKERS alone verifies current committed worker custody in the caller transaction.""" + + async def require_current_execution(self, facts: ExecuteFacts) -> None: + """Lock the exact fence/run and require the committed unexpired lease.""" + ... + class ExecutionAuthorityPort(Protocol): """Provide fresh authorization before acquiring an execution lease.""" diff --git a/backend/app/modules/checkers/api/materialization.py b/backend/app/modules/checkers/api/materialization.py index d67dccee9..c6ef11129 100644 --- a/backend/app/modules/checkers/api/materialization.py +++ b/backend/app/modules/checkers/api/materialization.py @@ -1,9 +1,15 @@ """Exact post-submit byte access; neither durable execution nor live authority.""" from dataclasses import dataclass +from abc import ABC, abstractmethod +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.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.post_submit import ( PostSubmissionEvaluationRequest, PostSubmissionEvaluationResult, @@ -18,6 +24,66 @@ class PostSubmissionMaterializationFailure(RuntimeError): """Retain a known post-authorization material failure after successful cleanup.""" +class MaterializationFacts(PostSubmitValue): + """Exact selected ancestry without provider coordinates or scratch authority.""" + + execution: ExecuteFacts + material: VerifiedMaterialFacts + evidence_id: ResourceId + verification_receipt_id: ResourceId + verification_job_id: ResourceId + verification_generation: VersionNumber + namespace_fingerprint: Sha256 + semantic_manifest_id: ResourceId + + +def materialization_authority_digest(facts: MaterializationFacts) -> str: + """Bind exact read custody without storing its raw facts in an audit payload.""" + if type(facts) is not MaterializationFacts: + raise ValueError("invalid materialization authority facts") + facts = MaterializationFacts.model_validate_json(facts.model_dump_json()) + request, material = facts.execution.request, facts.material + if (material.submission_id, material.submission_version, material.binding_id, + material.content_id, material.content_sha256, material.byte_count) != ( + request.submission_id, request.submission_version, request.binding_id, + request.content_id, request.content_sha256, request.byte_count, + ): + raise ValueError("materialization authority identity mismatch") + return canonical_json_hash({ + "domain": "workstream.authorization.post_submit_materialization", + "action_id": "artifact.post_submit.checker_input.materialize", + "permission_id": "artifact.checker_input.materialize", + "service_identity": "workstream.artifact.materializer", + "resource_type": "checker_run", + "resource_id": str(facts.execution.lease.reservation.attempt_id), + "scope_project_id": str(request.project_id), + "execution_digest": execution_authority_digest(facts.execution), + "material": facts.model_dump(mode="json", exclude={"execution"}), + }) + + +class PreparedMaterialization(ABC): + """A read's exact authority stays within its issuing transaction.""" + + @abstractmethod + async def consume(self, facts: MaterializationFacts) -> UUID: + """Record the authorized read before storage access.""" + ... + + @abstractmethod + async def validate_replay(self, facts: MaterializationFacts, evidence_id: UUID) -> None: + """Recheck the original read receipt after I/O without another audit row.""" + ... + + +class MaterializationAuthorityPort(Protocol): + """Prepare before CHECKERS currentness and ART selection in one short transaction.""" + + def prepare_materialization(self, facts: ExecuteFacts) -> AbstractAsyncContextManager[PreparedMaterialization]: + """Hold the materializer principal until exact facts are consumed or revalidated.""" + ... + + @dataclass(frozen=True, slots=True) class SubmissionMaterialEntry: """Expose verified archive metadata without a filesystem path.""" @@ -65,6 +131,6 @@ class PostSubmissionMaterializationResult: class PostSubmissionMaterializationPort(Protocol): """Verify and scope exact input without publishing durable checker results.""" async def materialize( - self, request: PostSubmissionEvaluationRequest, consumer: PostSubmissionMaterialConsumer, + self, facts: ExecuteFacts, consumer: PostSubmissionMaterialConsumer, ) -> PostSubmissionMaterializationResult: """Verify exact stored bytes and finish cleanup before returning phase facts.""" diff --git a/backend/app/modules/checkers/execution.py b/backend/app/modules/checkers/execution.py index 8a4d4e4e4..cd1177b9f 100644 --- a/backend/app/modules/checkers/execution.py +++ b/backend/app/modules/checkers/execution.py @@ -1,13 +1,13 @@ """One async post-submit execution path over reserved CHECKERS custody.""" import asyncio +import json from dataclasses import asdict from datetime import timedelta from uuid import UUID from collections.abc import Callable from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import ( COMPLETION_EVENT, @@ -20,6 +20,8 @@ ExecutionLease, FinalizeEvidence, FinalizeFacts, + FinalizeAuthorityFacts, + execution_authority_digest, PreparedExecution, PreparedFinalization, VerifiedMaterialFacts, @@ -64,9 +66,7 @@ def _lease(run): def _evidence(value, expected_type, facts): """Reject a wrong-phase receipt or a digest not bound to these facts.""" - if type(value) is not expected_type or value.facts_digest != canonical_json_hash( - facts.model_dump(mode="json") - ): + if type(value) is not expected_type or value.facts_digest != execution_authority_digest(facts): raise CheckerExecutionUnavailable("checker_action_evidence_unavailable") return value.evidence_id @@ -159,8 +159,13 @@ async def _claim(self, request): run = await repo.lock_current(request) if run.status in {"completed", "infrastructure_failed"}: facts = ExecuteFacts(request=request, lease=_lease(run)) - _evidence(await prepared.consume(facts), ExecuteEvidence, facts) - return None, stored_result(run) + await prepared.validate_replay(facts, UUID(run.execute_evidence_id)) + return None, FinalizeFacts( + request=request, lease=facts.lease, result=stored_result(run), + material=VerifiedMaterialFacts.model_validate_json(json.dumps(run.material_custody)) + if run.material_custody is not None else None, + output_binding_ids=(), + ) now = await repo.now() if run.status == "running" and run.worker_lease_expires_at > now: raise CheckerExecutionUnavailable("checker_execution_lease_unavailable") @@ -188,12 +193,12 @@ async def evaluate_post_submission( request = PostSubmissionEvaluationRequest.model_validate(request) lease, replay = await self._claim(request) if replay is not None: - return replay + return await self.finalize(replay) material = None try: async with asyncio.timeout(EXECUTION_TIMEOUT_SECONDS): materialized = await self._materialization.materialize( - request, _StructuralConsumer(self._registry, lease) + ExecuteFacts(request=request, lease=lease), _StructuralConsumer(self._registry, lease) ) material = VerifiedMaterialFacts( **{k: v for k, v in asdict(materialized).items() if k != "evaluation"} @@ -258,6 +263,11 @@ async def finalize(self, facts: FinalizeFacts) -> PostSubmissionEvaluationResult raise CheckerExecutionUnavailable("checker_finalization_authority_unavailable") repo = ExecutionRepository(session) run = await repo.lock_current(request) + 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), + ) if run.status in {"completed", "infrastructure_failed"}: if ( stored_result(run) != result @@ -266,10 +276,10 @@ async def finalize(self, facts: FinalizeFacts) -> PostSubmissionEvaluationResult != (facts.material.model_dump(mode="json") if facts.material else None) ): raise CheckerExecutionUnavailable("checker_finalization_replay_conflict") - _evidence(await prepared.consume(facts), FinalizeEvidence, facts) + 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(facts), FinalizeEvidence, 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": diff --git a/backend/app/modules/checkers/execution_authority.py b/backend/app/modules/checkers/execution_authority.py deleted file mode 100644 index 257b22443..000000000 --- a/backend/app/modules/checkers/execution_authority.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Action-specific denial until canonical AUTH installs execute/finalize custody.""" - -from app.modules.checkers.api.execution import CheckerExecutionUnavailable - - -class DenyExecutionAuthority: - """Keep production execution unavailable until live AUTH is installed.""" - - async def preflight(self, request): - """Deny execution before any prepared authority or material access.""" - raise CheckerExecutionUnavailable("post_submit_execution_unavailable") - - def prepare_execution(self, request): - """Refuse preparation even if a caller bypasses preflight.""" - raise CheckerExecutionUnavailable("post_submit_execution_unavailable") - - -class DenyFinalizationAuthority: - """Keep production finalization unavailable until live AUTH is installed.""" - - async def preflight(self, request): - """Deny publication before any prepared finalization authority.""" - raise CheckerExecutionUnavailable("post_submit_finalization_unavailable") - - def prepare_finalization(self, request): - """Refuse finalization preparation independently of preflight.""" - raise CheckerExecutionUnavailable("post_submit_finalization_unavailable") diff --git a/backend/app/modules/checkers/execution_coordination.py b/backend/app/modules/checkers/execution_coordination.py index 8972d22d3..5c47cc6c3 100644 --- a/backend/app/modules/checkers/execution_coordination.py +++ b/backend/app/modules/checkers/execution_coordination.py @@ -152,6 +152,22 @@ async def read_current_result( ) +class CurrentExecution: + """Expose only CHECKERS' current committed lease verification to materialization.""" + + def __init__(self, session: AsyncSession): + self._session = session + + async def require_current_execution(self, facts): + """Keep the caller's AUTH-first transaction and exact fence/run lock order.""" + from app.modules.checkers.api.execution import ExecuteFacts, execution_authority_digest + + if type(facts) is not ExecuteFacts: + raise CheckerExecutionUnavailable("checker_current_execution_unavailable") + execution_authority_digest(facts) + await ExecutionRepository(self._session).require_lease(facts.request, facts.lease) + + class CheckerOutputReservations: """Current structural handlers have exactly zero output slots.""" diff --git a/backend/app/modules/checkers/models.py b/backend/app/modules/checkers/models.py index 56500cf84..7e88e2769 100644 --- a/backend/app/modules/checkers/models.py +++ b/backend/app/modules/checkers/models.py @@ -146,8 +146,12 @@ class CheckerRun(Base): worker_lease_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) worker_lease_generation: Mapped[int] = mapped_column(Integer, nullable=False, default=0) worker_lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - execute_evidence_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) - finalize_evidence_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + execute_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", name="fk_checker_runs_execute_evidence", deferrable=True, initially="DEFERRED"), + ) + finalize_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", name="fk_checker_runs_finalize_evidence", deferrable=True, initially="DEFERRED"), + ) result_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False, unique=True) result_json: Mapped[str | None] = mapped_column(Text) result_digest: Mapped[str | None] = mapped_column(String(71)) diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 99e323144..1479600c8 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -146,6 +146,15 @@ "backend/app/modules/authorization/api/outbox_dispatch.py", } ) +ARCH_04D2_AUTHORITY_TARGETS = frozenset({ + "backend/app/modules/authorization/domain/post_submit.py", + "backend/app/modules/authorization/post_submit_authorization.py", + "backend/app/modules/authorization/prepared_post_submit_replay.py", +}) +ARCH_04D2_REMOVED_TARGETS = frozenset({ + "backend/app/modules/checkers/execution_authority.py", +}) + ARCH_04C_EXECUTION_TARGETS = frozenset({ "backend/app/modules/checkers/api/execution.py", "backend/app/modules/checkers/execution.py", @@ -677,7 +686,7 @@ def _validate_additive_partition_transition( ] if ( trusted_targets != sorted(trusted_targets) - or removed - (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 - (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 [current_by_target[item["target"]] for item in retained_trusted] != retained_trusted ): @@ -722,6 +731,7 @@ def _validate_additive_partition_transition( | ARCH_CP03B_ADAPTER_BINDING_AUTH_TARGETS | ARCH_CP04A_CONTRIBUTION_POLICY_TARGETS | ARCH_CP04B_CONTRIBUTION_POLICY_TARGETS + | ARCH_04D2_AUTHORITY_TARGETS | ARCH_04C_EXECUTION_TARGETS | ARCH_04B2_OUTPUT_TARGETS | ARCH_04B_MATERIALIZATION_TARGETS diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 5dff2148a..627765cb6 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -23,6 +23,14 @@ class TestLane: SHARED_FOUNDATION_MODULES = ( + "tests/authorization/post_submit/test_atomicity.py", + "tests/authorization/post_submit/test_concurrency.py", + "tests/authorization/post_submit/test_live_authority.py", + "tests/authorization/post_submit/test_migration.py", + "tests/authorization/post_submit/test_receipt_custody.py", + "tests/authorization/post_submit/test_principals.py", + "tests/authorization/post_submit/test_timeout.py", + "tests/authorization/project_roles/test_cancellation_postgresql.py", "tests/authorization/project_roles/test_constraint_postgresql.py", "tests/authorization/project_roles/test_lifecycle_postgresql.py", 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 f15d2f4a1..b64d85a9b 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", 75), ("admin-role-definitions", 5)]) +@pytest.mark.parametrize("surface,total", [("permissions", 77), ("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 14e34918e..f1ca081a7 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"]) == (75 if surface == "permissions" else 5) + assert body["total"] == len(body["items"]) == (77 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 6ddb0a0b1..b843260ef 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -19,6 +19,7 @@ "artifact.post_submit.checker_input.materialize", "artifact.review_packet.materialize", }, + ServiceIdentity.CHECKER_POST_SUBMIT: {"checker.post_submit.execute", "checker.post_submit.finalize"}, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: {"artifact.checker_output.write"}, ServiceIdentity.PROJECT_SETUP: { "project.guide_compilation.request_automatic", @@ -119,7 +120,7 @@ "artifact.post_submit.checker_input.materialize": ( "artifact.checker_input.materialize", "WS-AUTH-001-ART-06A", - "planned", + "active", ), "artifact.checker_output.write": ( "artifact.checker_output.write", @@ -250,7 +251,8 @@ audit.read audit.export""".split()) new_permissions = frozenset( - """task.assignment.authority_reconcile outbox.dispatch project.setup_diagnostic.read project.effective_policy.read + """ + 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 @@ -263,6 +265,8 @@ ) expected = { + "checker.post_submit.execute": ("checker.post_submit.execute", "WS-ARCH-001-04D2"), + "checker.post_submit.finalize": ("checker.post_submit.finalize", "WS-ARCH-001-04D2"), "task.submission.list": ("submission.read_own", "task-checker-auth-cleanup"), "submission.read": ("submission.read_own", "task-checker-auth-cleanup"), "submission.checker_run.list": ("submission.read_own", "task-checker-auth-cleanup"), @@ -432,6 +436,9 @@ AUDIT_ALLOWED_ACTION_VALUES = { + "checker.post_submit.execute", + "checker.post_submit.finalize", + "artifact.post_submit.checker_input.materialize", "task.submission.list", "submission.read", "submission.checker_run.list", diff --git a/backend/tests/authorization/post_submit/__init__.py b/backend/tests/authorization/post_submit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/authorization/post_submit/test_atomicity.py b/backend/tests/authorization/post_submit/test_atomicity.py new file mode 100644 index 000000000..de6c53ff7 --- /dev/null +++ b/backend/tests/authorization/post_submit/test_atomicity.py @@ -0,0 +1,65 @@ +"""Real authorization INSERT failures leave every protected phase unchanged.""" + +import pytest +from sqlalchemy import text + +from app.modules.checkers.api.execution import CheckerExecutionUnavailable, ExecuteFacts +from app.modules.checkers.api.materialization import PostSubmissionMaterializationUnavailable +from tests.checkers.execution.support import live_executor, reserve +from tests.checkers.execution.test_concurrency import final_facts +from tests.post_submit_materialization_helpers import material_fixture +from tests.test_post_submit_materialization import Consumer +from .test_receipt_custody import snapshot + + +@pytest.mark.parametrize("phase", ["execute", "materialize", "finalize"]) +async def test_real_audit_insert_failure_rolls_back(tmp_path, isolated_database_env, phase): + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + executor = live_executor(h) + lease = None + if phase != "execute": + lease, _ = await executor._claim(h.request) + action = ("artifact.post_submit.checker_input.materialize" if phase == "materialize" + else "checker.post_submit." + phase) + before = await snapshot(h) + opened = len(h.store.opens) + consumer = Consumer(h.files) + async with h.factory() as session, session.begin(): + await session.execute(text("CREATE SEQUENCE test_post_submit_audit_attempt")) + await session.execute(text("""CREATE FUNCTION test_post_submit_audit_failure() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN + PERFORM nextval('test_post_submit_audit_attempt'); + RAISE EXCEPTION 'post-submit audit insert rejected' USING ERRCODE='23514'; + END $$""")) + # Action is a closed test parameter, never caller input. + await session.execute(text(f"""CREATE TRIGGER test_post_submit_audit_failure + BEFORE INSERT ON audit_events FOR EACH ROW WHEN (NEW.action_id='{action}') + EXECUTE FUNCTION test_post_submit_audit_failure()""")) + + async def invoke(): + if phase == "execute": + return await executor._claim(h.request) + if phase == "materialize": + return await h.service.materialize(ExecuteFacts(request=h.request, lease=lease), consumer) + return await executor.finalize(final_facts(h, lease)) + + try: + error = PostSubmissionMaterializationUnavailable if phase == "materialize" else CheckerExecutionUnavailable + with pytest.raises(error, match="authority_unavailable"): + await invoke() + assert await snapshot(h) == before + assert len(h.store.opens) == opened + assert consumer.calls == 0 and not h.preparation._active + assert list((h.scratch / "workspaces").iterdir()) == [] + async with h.factory() as session: + # Sequence effects survive rollback: the real INSERT trigger ran. + assert (await session.execute(text( + "SELECT last_value,is_called FROM test_post_submit_audit_attempt" + ))).one() == (1, True) + finally: + async with h.factory() as session, session.begin(): + await session.execute(text("DROP TRIGGER test_post_submit_audit_failure ON audit_events")) + await session.execute(text("DROP FUNCTION test_post_submit_audit_failure()")) + await session.execute(text("DROP SEQUENCE test_post_submit_audit_attempt")) + assert await invoke() is not None diff --git a/backend/tests/authorization/post_submit/test_concurrency.py b/backend/tests/authorization/post_submit/test_concurrency.py new file mode 100644 index 000000000..91a1ee6e1 --- /dev/null +++ b/backend/tests/authorization/post_submit/test_concurrency.py @@ -0,0 +1,89 @@ +"""Independent transactions serialize finalization against live service revocation.""" + +import asyncio + +import pytest +from sqlalchemy import event, text + +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.post_submit_authorization import _PreparedFinalize +from app.modules.checkers.api.execution import CheckerExecutionUnavailable +from tests.checkers.execution.support import live_executor, reserve, service_link_state +from tests.checkers.execution.test_concurrency import final_facts +from tests.post_submit_materialization_helpers import material_fixture +from .test_receipt_custody import snapshot + + +@pytest.mark.parametrize("finalization_first", [True, False]) +async def test_revocation_and_finalization_serialize(tmp_path, isolated_database_env, monkeypatch, finalization_first): + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + executor = live_executor(h) + lease, _ = await executor._claim(h.request) + facts = final_facts(h, lease) + before = await snapshot(h) + held, release = asyncio.Event(), asyncio.Event() + original = _PreparedFinalize.consume + + async def paused_consume(prepared, value): + receipt = await original(prepared, value) + held.set() + await asyncio.wait_for(release.wait(), 10) + return receipt + + async def revoke_while_held(): + async with h.factory() as session, session.begin(): + 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": ServiceIdentity.CHECKER_POST_SUBMIT.value}) + held.set() + await asyncio.wait_for(release.wait(), 10) + + if finalization_first: + monkeypatch.setattr(_PreparedFinalize, "consume", paused_consume) + first = asyncio.create_task(executor.finalize(facts)) + else: + first = asyncio.create_task(revoke_while_held()) + second = None + waiting_pid = asyncio.Queue() + def capture_waiter(connection, cursor, statement, parameters, context, executemany): + if asyncio.current_task() is second and ("FOR UPDATE" in statement or statement.lstrip().lower().startswith("update actor_identity_links")): + waiting_pid.put_nowait(connection.connection.driver_connection.get_server_pid()) + event.listen(h.engine.sync_engine, "before_cursor_execute", capture_waiter) + try: + await asyncio.wait_for(held.wait(), 10) + second = asyncio.create_task( + service_link_state(h.factory, ServiceIdentity.CHECKER_POST_SUBMIT, active=False) + if finalization_first else executor.finalize(facts) + ) + pid = await asyncio.wait_for(waiting_pid.get(), 5) + blocked = False + async with h.factory() as observer: + for _ in range(100): + blocked = bool(await observer.scalar(text( + "SELECT cardinality(pg_blocking_pids(:pid))>0" + ), {"pid": pid})) + if blocked or second.done(): + break + await asyncio.sleep(0.02) + assert blocked and not second.done(), "competing authority operation did not wait on live principal custody" + release.set() + await asyncio.wait_for(first, 10) + if finalization_first: + await asyncio.wait_for(second, 10) + after = await snapshot(h) + assert after[0][0]["status"] == "completed" + assert after[1] == before[1] + 1 + assert after[2] == len(h.request.policy.entries) + assert after[3] == before[3] + 1 + else: + with pytest.raises(CheckerExecutionUnavailable): + await asyncio.wait_for(second, 10) + assert await snapshot(h) == before + await service_link_state(h.factory, ServiceIdentity.CHECKER_POST_SUBMIT, active=True) + assert await executor.finalize(facts) == facts.result + finally: + release.set() + await asyncio.gather(first, *([second] if second else []), return_exceptions=True) + event.remove(h.engine.sync_engine, "before_cursor_execute", capture_waiter) diff --git a/backend/tests/authorization/post_submit/test_live_authority.py b/backend/tests/authorization/post_submit/test_live_authority.py new file mode 100644 index 000000000..6f2cfd207 --- /dev/null +++ b/backend/tests/authorization/post_submit/test_live_authority.py @@ -0,0 +1,167 @@ +"""Exact live authority, replay and revocation through production composition.""" + +import asyncio +from datetime import timedelta +from uuid import UUID + +import pytest + +from app.modules.actors.api import ServiceIdentity +from app.modules.audit.service import AuditService +from app.modules.checkers.api.execution import CheckerExecutionUnavailable, ExecuteFacts +from app.modules.checkers.api.materialization import PostSubmissionMaterializationUnavailable +from app.modules.checkers.models import CheckerRun +from tests.checkers.execution.support import live_executor, material_execution, reserve, service_link_state +from tests.post_submit_materialization_helpers import material_fixture +from tests.test_post_submit_materialization import Consumer +from tests.checkers.post_submit.support import change_request +from .test_receipt_custody import snapshot + + +async def test_terminal_replay_validates_both_stored_receipts_without_side_effects( + tmp_path, isolated_database_env, monkeypatch, +): + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + executor = live_executor(h) + result = await executor.evaluate_post_submission(h.request) + before, opened = await snapshot(h), len(h.store.opens) + assert await executor.evaluate_post_submission(h.request) == result + assert await snapshot(h) == before and len(h.store.opens) == opened + async with h.factory() as session: + final_id = UUID((await session.get(CheckerRun, str(result.attempt_id))).finalize_evidence_id) + original, seen = AuditService.get_authority_event, [] + + async def hide_final(service, event_id): + seen.append(event_id) + return None if event_id == final_id else await original(service, event_id) + + with monkeypatch.context() as patch: + patch.setattr(AuditService, "get_authority_event", hide_final) + with pytest.raises(CheckerExecutionUnavailable, match="authority_unavailable"): + await executor.evaluate_post_submission(h.request) + assert final_id in seen and len(seen) == 2 + assert await snapshot(h) == before and len(h.store.opens) == opened + await service_link_state(h.factory, ServiceIdentity.CHECKER_POST_SUBMIT, active=False) + with pytest.raises(CheckerExecutionUnavailable): + await executor.evaluate_post_submission(h.request) + assert await snapshot(h) == before and len(h.store.opens) == opened + await service_link_state(h.factory, ServiceIdentity.CHECKER_POST_SUBMIT, active=True) + assert await executor.evaluate_post_submission(h.request) == result + + +@pytest.mark.parametrize("field", ["lease_id", "lease_generation", "expires_at"]) +async def test_materialization_rejects_substituted_live_lease_before_io( + tmp_path, isolated_database_env, field, +): + from app.core.identifiers import new_record_id + async with material_fixture(tmp_path, isolated_database_env) as h: + facts = await material_execution(h) + changed = {"lease_id": new_record_id(), "lease_generation": facts.lease.lease_generation+1, + "expires_at": facts.lease.expires_at + timedelta(microseconds=1)}[field] + substituted = facts.model_copy(update={"lease": facts.lease.model_copy(update={field: changed})}) + before = await snapshot(h) + consumer = Consumer(h.files) + with pytest.raises(PostSubmissionMaterializationUnavailable): + await h.service.materialize(substituted, consumer) + assert await snapshot(h) == before + assert h.store.opens == [] and consumer.calls == 0 and not h.preparation._active + await h.service.materialize(facts, consumer) + assert consumer.calls == 1 + + +@pytest.mark.parametrize("field", ["binding_id", "content_id", "submission_id"]) +async def test_materialization_rejects_mixed_valid_stored_lineage_before_io( + tmp_path, isolated_database_env, field, +): + async with material_fixture(tmp_path / "one", isolated_database_env) as h: + async with material_fixture(tmp_path / "two", isolated_database_env, + provision_services=False, storage_settings=h.settings) as other: + facts = await material_execution(h) + await material_execution(other) + mixed_request = change_request(h.request, **{field: getattr(other.request, field)}) + mixed_reservation = facts.lease.reservation.model_copy(update={ + "request_digest": mixed_request.request_sha256, + }) + mixed = ExecuteFacts(request=mixed_request, + lease=facts.lease.model_copy(update={"reservation": mixed_reservation})) + # Well-typed internally consistent selectors mix two genuine stored + # lineages; they do not match either committed current execution. + before = await snapshot(h) + consumer = Consumer(h.files) + with pytest.raises(PostSubmissionMaterializationUnavailable): + await h.service.materialize(mixed, consumer) + assert await snapshot(h) == before + assert h.store.opens == [] and consumer.calls == 0 and not h.preparation._active + await h.service.materialize(facts, consumer) + assert consumer.calls == 1 + + +@pytest.mark.parametrize("identity", [ServiceIdentity.ARTIFACT_MATERIALIZER, ServiceIdentity.CHECKER_POST_SUBMIT]) +async def test_revoked_after_consumer_cannot_publish(tmp_path, isolated_database_env, monkeypatch, identity): + import app.modules.checkers.execution as execution + 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 + calls = [] + + async def paused(consumer, request, material): + value = await original(consumer, request, material) + calls.append(value) + entered.set() + await release.wait() + return value + + monkeypatch.setattr(execution._StructuralConsumer, "evaluate", paused) + operation = asyncio.create_task(live_executor(h).evaluate_post_submission(h.request)) + try: + await asyncio.wait_for(entered.wait(), 15) + before = await snapshot(h) + await service_link_state(h.factory, identity, active=False) + release.set() + expected = PostSubmissionMaterializationUnavailable if identity is ServiceIdentity.ARTIFACT_MATERIALIZER else CheckerExecutionUnavailable + with pytest.raises(expected): + await asyncio.wait_for(operation, 15) + assert await snapshot(h) == before + assert len(h.store.opens) == len(calls) == 1 and not h.preparation._active + async with h.factory() as session: + run = await session.get(CheckerRun, str(reserved.attempt_id)) + assert run.status == "running" and run.execute_evidence_id is not None + assert run.finalize_evidence_id is run.result_json is run.material_custody is run.completion_event_id is None + finally: + release.set() + await asyncio.gather(operation, return_exceptions=True) + + +@pytest.mark.parametrize("field", ["attempt_id", "result_id", "evaluation_request_id", "evaluation_generation", "project_id"]) +async def test_execution_identity_substitution_denies_before_material_access(tmp_path, isolated_database_env, field): + async with material_fixture(tmp_path / "one", isolated_database_env) as h: + async with material_fixture(tmp_path / "two", isolated_database_env, + provision_services=False, storage_settings=h.settings) as other: + facts = await material_execution(h) + foreign = await material_execution(other) + request, reservation = facts.request, facts.lease.reservation + if field in {"attempt_id", "result_id"}: + 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) + elif field == "evaluation_request_id": + request = change_request(request, evaluation_request_id=other.request.evaluation_request_id) + else: + request = change_request(request, evaluation_generation=request.evaluation_generation + 1) + reservation = reservation.model_copy(update={ + "request_digest": request.request_sha256, "request_id": request.evaluation_request_id, + "evaluation_generation": request.evaluation_generation, + }) + mixed = ExecuteFacts(request=request, lease=facts.lease.model_copy(update={"reservation": reservation})) + before = await snapshot(h) + consumer = Consumer(h.files) + with pytest.raises(PostSubmissionMaterializationUnavailable): + await h.service.materialize(mixed, consumer) + assert await snapshot(h) == before + assert not h.store.opens and consumer.calls == 0 and not h.preparation._active + await h.service.materialize(facts, consumer) + assert consumer.calls == 1 diff --git a/backend/tests/authorization/post_submit/test_migration.py b/backend/tests/authorization/post_submit/test_migration.py new file mode 100644 index 000000000..a8b9591e6 --- /dev/null +++ b/backend/tests/authorization/post_submit/test_migration.py @@ -0,0 +1,109 @@ +"""The actual predecessor upgrade preserves queued work and refuses invented receipts.""" + +import asyncio + +import asyncpg +import pytest +from alembic import command +from sqlalchemy.exc import IntegrityError + +from app.db import session as db_session +from tests.migration_fixtures import _config +from tests.checkers.execution.predecessor_support import predecessor_lease +from tests.checkers.execution.test_material_migration import retained_snapshot +from tests.checkers.execution.support import reserve +from tests.post_submit_materialization_helpers import material_fixture + +pytestmark = pytest.mark.postgres_schema_contract + + +@pytest.mark.parametrize("unprovable_receipt", [False, True]) +async def test_actual_upgrade_preserves_or_refuses_without_repair(tmp_path, isolated_database_env, migration_lock, unprovable_receipt): + 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(), "0009_checker_material_lineage") + async with material_fixture(tmp_path, isolated_database_env, provision_checker=False) as h: + await reserve(h) + if unprovable_receipt: + await predecessor_lease(h) + before = await retained_snapshot(h.factory) + if unprovable_receipt: + with pytest.raises(IntegrityError, match="retained checker authorization receipts are unprovable"): + await asyncio.to_thread(command.upgrade, _config(), "head") + assert await retained_snapshot(h.factory) == before + else: + await asyncio.to_thread(command.upgrade, _config(), "head") + after = await retained_snapshot(h.factory) + assert after[0] == before[0] + assert after[1] == "0010_post_submit_authority" + + +async def test_upgrade_excludes_writer_across_receipt_preflight(tmp_path, isolated_database_env, migration_lock, monkeypatch): + import threading + from alembic.operations import Operations + from sqlalchemy import text + + 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(), "0009_checker_material_lineage") + async with material_fixture(tmp_path, isolated_database_env, provision_checker=False) as h: + reserved = await reserve(h) + scanned, resume = threading.Event(), threading.Event() + execute = Operations.execute + def pause_after_scan(operations, sql, *args, **kwargs): + result = execute(operations, sql, *args, **kwargs) + if isinstance(sql, str) and sql.lstrip().startswith("DO $$ BEGIN") and "retained checker authorization receipts" in sql: + scanned.set() + assert resume.wait(20), "receipt migration scan was not released" + return result + monkeypatch.setattr(Operations, "execute", pause_after_scan) + migration = asyncio.create_task(asyncio.to_thread(command.upgrade, _config(), "head")) + writer = None + writer_pid = asyncio.Queue() + async def write_predecessor_receipt(): + from app.core.identifiers import new_record_id + async with h.factory() as session, session.begin(): + await writer_pid.put(await session.scalar(text("SELECT pg_backend_pid()"))) + await session.execute(text("""UPDATE checker_runs SET status='running', + 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}) + try: + assert await asyncio.to_thread(scanned.wait, 10) + writer = asyncio.create_task(write_predecessor_receipt()) + pid = await asyncio.wait_for(writer_pid.get(), 5) + blocked = False + async with h.factory() as observer: + for _ in range(100): + blocked = bool(await observer.scalar(text( + "SELECT cardinality(pg_blocking_pids(:pid))>0" + ), {"pid": pid})) + if blocked or writer.done(): + break + await asyncio.sleep(0.02) + assert blocked and not writer.done(), "writer crossed the receipt preflight/install gap" + resume.set() + await asyncio.wait_for(migration, 10) + 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() + assert row == ("queued", None, None) + from tests.checkers.execution.support import provision_checker_service, live_executor + await provision_checker_service(h.factory) + valid, _ = await live_executor(h)._claim(h.request) + 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_principals.py b/backend/tests/authorization/post_submit/test_principals.py new file mode 100644 index 000000000..a57ea9b29 --- /dev/null +++ b/backend/tests/authorization/post_submit/test_principals.py @@ -0,0 +1,79 @@ +"""Persisted service lifecycle and the closed service/action matrix govern each phase.""" + +import pytest +from sqlalchemy import text + +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.domain.post_submit import EXECUTE, FINALIZE, MATERIALIZE, post_submit_prepare_values +from app.modules.authorization.prepared import fixed_service_prepared_authorization +from app.modules.authorization.runtime import ( + PreparedAuthorizationInput, PreparedAuthorityScope, PreparedAuthorityScopeKind, + PreparedAuthorizationUnsupported, +) +from app.modules.checkers.api.execution import CheckerExecutionUnavailable, ExecuteFacts +from app.modules.checkers.api.materialization import PostSubmissionMaterializationUnavailable +from tests.checkers.execution.support import live_executor, reserve +from tests.checkers.execution.test_concurrency import final_facts +from tests.post_submit_materialization_helpers import material_fixture +from tests.test_post_submit_materialization import Consumer +from .test_receipt_custody import snapshot + + +@pytest.mark.parametrize("phase", ["execute", "materialize", "finalize"]) +async def test_inactive_fixed_actor_denies_phase(tmp_path, isolated_database_env, phase): + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + executor = live_executor(h) + lease = None + if phase != "execute": + lease, _ = await executor._claim(h.request) + identity = ServiceIdentity.ARTIFACT_MATERIALIZER if phase == "materialize" else ServiceIdentity.CHECKER_POST_SUBMIT + async with h.factory() as session, session.begin(): + await session.execute(text("""UPDATE actor_profiles SET status='suspended', + suspended_by='test',suspended_at=clock_timestamp(),suspension_reason='test' + WHERE service_identity=:identity"""), {"identity": identity.value}) + before = await snapshot(h) + consumer = Consumer(h.files) + async def invoke(): + if phase == "execute": + return await executor._claim(h.request) + if phase == "materialize": + return await h.service.materialize(ExecuteFacts(request=h.request, lease=lease), consumer) + return await executor.finalize(final_facts(h, lease)) + expected = PostSubmissionMaterializationUnavailable if phase == "materialize" else CheckerExecutionUnavailable + with pytest.raises(expected): + await invoke() + assert await snapshot(h) == before + assert not h.store.opens and not h.preparation._active and consumer.calls == 0 + # Restore the fixture to prove that the same complete operation is valid. + async with h.factory() as session, session.begin(): + await session.execute(text("""UPDATE actor_profiles SET status='active', suspended_by=NULL, + suspended_at=NULL,suspension_reason=NULL,deactivated_by=NULL,deactivated_at=NULL, + deactivation_reason=NULL,reactivated_by='test',reactivated_at=clock_timestamp(), + reactivation_reason='test' WHERE service_identity=:identity"""), {"identity": identity.value}) + assert await invoke() is not None + + +@pytest.mark.parametrize("action", [EXECUTE, FINALIZE, MATERIALIZE]) +async def test_real_foreign_service_cannot_prepare_phase(tmp_path, isolated_database_env, action): + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + lease, _ = await live_executor(h)._claim(h.request) + execution = ExecuteFacts(request=h.request, lease=lease) + canonical = ServiceIdentity.ARTIFACT_MATERIALIZER if action == MATERIALIZE else ServiceIdentity.CHECKER_POST_SUBMIT + foreign = ServiceIdentity.CHECKER_POST_SUBMIT if action == MATERIALIZE else ServiceIdentity.ARTIFACT_MATERIALIZER + caller = PreparedAuthorizationInput(idempotency_key=h.request.evaluation_request_id, + request_value=post_submit_prepare_values(action, h.request, execution if action == MATERIALIZE else None)) + scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=h.request.project_id) + before = await snapshot(h) + async with h.factory() as session, session.begin(): + async with fixed_service_prepared_authorization(session, service_identity=foreign, + request_id=h.request.evaluation_request_id, correlation_id=h.request.evaluation_request_id) as authority: + with pytest.raises(PreparedAuthorizationUnsupported): + await authority.service.prepare(action, caller, scope) + assert await snapshot(h) == before + async with h.factory() as session, session.begin(): + async with fixed_service_prepared_authorization(session, service_identity=canonical, + request_id=h.request.evaluation_request_id, correlation_id=h.request.evaluation_request_id) as authority: + assert await authority.service.prepare(action, caller, scope) is not None + assert await snapshot(h) == before diff --git a/backend/tests/authorization/post_submit/test_receipt_custody.py b/backend/tests/authorization/post_submit/test_receipt_custody.py new file mode 100644 index 000000000..ddf397c7f --- /dev/null +++ b/backend/tests/authorization/post_submit/test_receipt_custody.py @@ -0,0 +1,242 @@ +"""Real AUTH receipts cannot be borrowed for another run, lease or terminal outcome.""" + +from contextlib import nullcontext +from datetime import timedelta +from uuid import UUID + +import pytest +from sqlalchemy import func, select, text +from sqlalchemy.exc import IntegrityError + +from app.adapters.auth import post_submit_execution_authority +from app.core.identifiers import new_record_id +from app.modules.checkers.api.execution import ( + ExecuteFacts, ExecutionLease, FinalizeAuthorityFacts, execution_authority_digest, +) +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.outbox.models import OutboxEvent +from app.modules.tasks.models import AuditEvent +from tests.checkers.execution.support import live_executor, reserve +from tests.checkers.execution.test_concurrency import final_facts +from tests.checkers.execution.material_storage_helpers import stage_terminal +from tests.post_submit_materialization_helpers import material_fixture +from tests.test_post_submit_materialization import Consumer + + +async def snapshot(h): + async with h.factory() as session: + return ( + list((await session.scalars(text("select to_jsonb(r) from checker_runs r order by id"))).all()), + await session.scalar(select(func.count()).select_from(AuditEvent)), + await session.scalar(select(func.count()).select_from(CheckerResult)), + await session.scalar(select(func.count()).select_from(OutboxEvent)), + ) + + +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(): + async with post_submit_execution_authority(session).prepare_execution(h.request) as prepared: + repo = ExecutionRepository(session) + run = await repo.lock_current(h.request) + lease = ExecutionLease( + reservation=reservation(run), lease_id=new_record_id(), + lease_generation=run.worker_lease_generation + 1, + expires_at=await repo.now() + timedelta(seconds=300), + ) + facts = ExecuteFacts(request=h.request, lease=lease) + receipt = await prepared.consume(facts) + run.worker_lease_id = str(lease.lease_id) + run.worker_lease_generation = lease.lease_generation + run.worker_lease_expires_at = lease.expires_at + run.execute_evidence_id = str(borrowed if borrowed is not None else receipt.evidence_id) + await session.flush() + if shadow: + for table in ("checker_runs", "audit_events", "actor_profiles", "actor_identity_links"): + await session.execute(text(f"CREATE TEMP TABLE {table} ON COMMIT DROP AS SELECT * FROM public.{table} WHERE false")) + await session.execute(text("SET LOCAL search_path = pg_temp, public, pg_catalog")) + if borrowed is None: + digest = await session.scalar(text( + "select public.checker_post_submit_authority_digest(r,'execute') " + "from public.checker_runs r where r.id=:id" + ), {"id": run.id}) + assert digest == execution_authority_digest(facts) + return lease + + +@pytest.mark.parametrize("substitution", ["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) + async with h.factory() as session: + run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) + borrowed = UUID(run.execute_evidence_id) + if substitution == "foreign_attempt": + async with material_fixture(tmp_path / "two", isolated_database_env, + provision_services=False, storage_settings=h.settings) as other: + await reserve(other) + foreign, _ = await live_executor(other)._claim(other.request) + async with h.factory() as session: + borrowed = UUID((await session.get(CheckerRun, str(foreign.reservation.attempt_id))).execute_evidence_id) + elif substitution == "material_read": + await h.service.materialize(ExecuteFacts(request=h.request, lease=lease), Consumer(h.files)) + async with h.factory() as session: + borrowed = UUID(await session.scalar(select(AuditEvent.id).where( + AuditEvent.action_id == "artifact.post_submit.checker_input.materialize", + AuditEvent.resource_id == str(lease.reservation.attempt_id), + ))) + elif substitution == "nonexistent": + borrowed = new_record_id() + await wait_for_expiry(h, lease) + before = await snapshot(h) + with pytest.raises(IntegrityError, match="receipt custody|foreign key"): + await next_lease(h, borrowed, shadow=substitution == "shadow_tables") + assert await snapshot(h) == before + valid = await next_lease(h) + assert valid.lease_generation == lease.lease_generation + 1 + + +@pytest.mark.parametrize("substitution", ["result", "material", "execute_chain"]) +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) + old_execute = None + if substitution == "execute_chain": + async with h.factory() as session: + old_execute = UUID((await session.get(CheckerRun, str(lease.reservation.attempt_id))).execute_evidence_id) + await wait_for_expiry(h, lease) + lease = await next_lease(h) + source = final_facts(h, lease) + result = make_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", + ) + facts = source.model_copy(update={"result": result}) + stored = facts + if substitution == "result": + changed = make_post_submit_result(**(result.model_dump(exclude={"result_digest"}) | { + "infrastructure_failure_code": "invalid_output", + })) + stored = facts.model_copy(update={"result": changed}) + elif substitution == "material": + stored = facts.model_copy(update={"material": None}) + before = await snapshot(h) + with pytest.raises(IntegrityError, match="receipt custody"): + async with h.factory() as session, session.begin(): + async with post_submit_execution_authority(session).prepare_finalization(h.request) as prepared: + run = await ExecutionRepository(session).require_lease(h.request, lease) + receipt = await prepared.consume(FinalizeAuthorityFacts( + **facts.model_dump(), execute_evidence_id=old_execute or UUID(run.execute_evidence_id), + )) + await stage_terminal(session, stored, + stored.material.model_dump(mode="json") if stored.material else None, receipt.evidence_id) + assert await snapshot(h) == before + assert await live_executor(h).finalize(facts) == facts.result + async with h.factory() as session: + run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) + digest = await session.scalar(text( + "select public.checker_post_submit_authority_digest(r,'finalize') " + "from public.checker_runs r where r.id=:id" + ), {"id": run.id}) + assert digest == execution_authority_digest(FinalizeAuthorityFacts( + **facts.model_dump(), execute_evidence_id=UUID(run.execute_evidence_id), + )) + + +async def wait_for_expiry(h, lease): + async with h.factory() as session: + await session.execute(text( + "select pg_sleep(greatest(0,extract(epoch from cast(:expiry as timestamptz)-clock_timestamp()))+0.02)" + ), {"expiry": lease.expires_at}) + + +@pytest.mark.parametrize("outcome", ["completed", "infrastructure_failed"]) +async def test_terminal_receipt_digest_matches_database(tmp_path, isolated_database_env, outcome): + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + executor = live_executor(h) + lease, _ = await executor._claim(h.request) + facts = final_facts(h, lease) + if outcome == "infrastructure_failed": + facts = facts.model_copy(update={"material": None, "result": make_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=(), infrastructure_failure_code="material_unavailable", + )}) + assert await executor.finalize(facts) == facts.result + async with h.factory() as session: + run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) + expected = FinalizeAuthorityFacts(**facts.model_dump(), execute_evidence_id=UUID(run.execute_evidence_id)) + assert await session.scalar(text( + "select public.checker_post_submit_authority_digest(r,'finalize') from public.checker_runs r where r.id=:id" + ), {"id": run.id}) == execution_authority_digest(expected) + + +@pytest.mark.parametrize("missing_execute", [True, False]) +async def test_final_receipt_independently_requires_execute_receipt( + tmp_path, isolated_database_env, missing_execute, +): + """Isolate semantic receipt custody from the earlier run-state/immutability guard.""" + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + executor = live_executor(h) + lease, _ = await executor._claim(h.request) + facts = final_facts(h, lease) + facts = facts.model_copy(update={"material": None, "result": make_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", + )}) + await executor.finalize(facts) + before = await snapshot(h) + replacement = new_record_id() + expected = pytest.raises(IntegrityError, match="checker authorization receipt custody mismatch") + with expected if missing_execute else nullcontext(): + async with h.factory() as session, session.begin(): + run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) + original_final = run.finalize_evidence_id + original_execute = run.execute_evidence_id + # Disable only the earlier immediate state guard. All deferred + # receipt, foreign-key, material and terminal guards remain live. + # Transactional DDL rolls back with the rejected write. + await session.execute(text("ALTER TABLE public.checker_runs DISABLE TRIGGER checker_run_custody")) + await session.execute(text(""" + UPDATE public.checker_runs SET execute_evidence_id=:execute, + finalize_evidence_id=:final WHERE id=:id + """), {"execute": None if missing_execute else original_execute, + "final": replacement, "id": run.id}) + # Keep real service/action provenance and every other audit fact; + # only the event ID and digest follow the staged receipt chain. + await session.execute(text(""" + INSERT INTO public.audit_events + SELECT (jsonb_populate_record(NULL::public.audit_events, + to_jsonb(a) || jsonb_build_object('id',cast(:final AS text), + 'entity_id',cast(:final AS text), + 'after_facts',jsonb_build_object('allowed',true, + 'resource_context_digest', + public.checker_post_submit_authority_digest(r,'finalize'))))).* + FROM public.audit_events a CROSS JOIN public.checker_runs r + WHERE a.id=:original AND r.id=:id + """), {"final": str(replacement), "original": original_final, "id": run.id}) + await session.execute(text("SET CONSTRAINTS ALL IMMEDIATE")) + await session.execute(text("ALTER TABLE public.checker_runs ENABLE TRIGGER checker_run_custody")) + if missing_execute: + assert await snapshot(h) == before + else: + async with h.factory() as session: + run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) + assert run.execute_evidence_id == original_execute + assert run.finalize_evidence_id == str(replacement) diff --git a/backend/tests/authorization/post_submit/test_timeout.py b/backend/tests/authorization/post_submit/test_timeout.py new file mode 100644 index 000000000..61954f3fd --- /dev/null +++ b/backend/tests/authorization/post_submit/test_timeout.py @@ -0,0 +1,158 @@ +"""Executor deadlines must not bypass post-I/O material authority and selection.""" + +import asyncio + +import pytest +from sqlalchemy import text + +from app.modules.actors.api import ServiceIdentity +from app.modules.checkers.api.materialization import PostSubmissionMaterializationUnavailable +from tests.checkers.execution.support import live_executor, reserve, service_link_state +from tests.post_submit_materialization_helpers import material_fixture +from .test_receipt_custody import snapshot + + +@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): + import app.modules.checkers.execution as execution + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + entered, aborted = asyncio.Event(), [] + async def paused(consumer, request, material): + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + aborted.append(True) + if consumer_failure != "cancel": + error = RuntimeError if consumer_failure == "runtime_error" else TimeoutError + raise error("consumer failed after abort") from None + raise + monkeypatch.setattr(execution._StructuralConsumer, "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) + before = await snapshot(h) + if change == "revoked": + await service_link_state(h.factory, ServiceIdentity.ARTIFACT_MATERIALIZER, active=False) + elif change == "replica_unavailable": + async with h.factory() as session, session.begin(): + await session.execute(text("UPDATE artifact_replicas SET availability_state='unavailable' WHERE id=:id"), {"id": h.replica_id}) + assert len(deadlines) == 1 + deadlines[0].reschedule(asyncio.get_running_loop().time()) + if change == "unchanged": + result = await asyncio.wait_for(task, 10) + assert result.outcome == "infrastructure_failed" + assert result.infrastructure_failure_code == "deadline_exceeded" + after = await snapshot(h) + assert after[0][0]["status"] == "infrastructure_failed" + assert after[0][0]["material_custody"] is None + assert after[1] == before[1] + 1 # finalization only; no second read allow + assert after[2:] == before[2:] + else: + with pytest.raises(PostSubmissionMaterializationUnavailable): + await asyncio.wait_for(task, 10) + assert await snapshot(h) == before + assert aborted == [True] + assert len(h.store.opens) == 1 and not h.preparation._active + assert list((h.scratch / "workspaces").iterdir()) == [] + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +def controlled_deadline(monkeypatch, execution): + """Use the real timeout, armed only after the boundary under test is reached.""" + original_timeout, deadlines = asyncio.timeout, [] + def timeout(seconds): + timer = original_timeout(None if seconds == 12345 else seconds) + if seconds == 12345: + deadlines.append(timer) + return timer + monkeypatch.setattr(execution, "EXECUTION_TIMEOUT_SECONDS", 12345) + monkeypatch.setattr(asyncio, "timeout", timeout) + return deadlines + + +@pytest.mark.parametrize("cleanup_error", ["integrity", "deadline"]) +@pytest.mark.parametrize("failure", ["release", "workspace", "projection"]) +async def test_outer_deadline_cannot_hide_failed_scratch_cleanup(tmp_path, isolated_database_env, monkeypatch, failure, cleanup_error): + import app.modules.checkers.execution as execution + from app.modules.artifacts.preparation import ArtifactScratchIntegrityError, ArtifactPreparationDeadlineError + + async with material_fixture(tmp_path, isolated_database_env) as h: + await reserve(h) + entered, finish = asyncio.Event(), asyncio.Event() + error_type = ArtifactScratchIntegrityError if cleanup_error == "integrity" else ArtifactPreparationDeadlineError + original_release = h.manager.release + original_workspace = h.manager._cleanup_workspace_sync + original_projection = h.inspector._projected_tree + async def failed_release(reservation): + entered.set() + await finish.wait() + raise error_type("controlled release failure") + async def paused_consumer(consumer, request, material): + entered.set() + await asyncio.Event().wait() + from contextlib import contextmanager + @contextmanager + def failed_projection(*args, **kwargs): + with original_projection(*args, **kwargs) as tree: + yield tree + raise (OSError if cleanup_error == "integrity" else TimeoutError)("controlled projection cleanup failure") + def failed_workspace(name): + raise error_type("controlled workspace failure") + if failure == "release": + monkeypatch.setattr(h.manager, "release", failed_release) + else: + monkeypatch.setattr(execution._StructuralConsumer, "evaluate", paused_consumer) + if failure == "workspace": + monkeypatch.setattr(h.manager, "_cleanup_workspace_sync", failed_workspace) + else: + monkeypatch.setattr(h.inspector, "_projected_tree", failed_projection) + 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) + before = await snapshot(h) + deadlines[0].reschedule(asyncio.get_running_loop().time()) + for _ in range(10): + await asyncio.sleep(0) + if task.cancelling(): + break + assert task.cancelling(), "executor deadline did not cancel the protected operation" + finish.set() + expected = ("post_submit_projection_cleanup_unconfirmed" if failure == "projection" else + f"controlled {failure} failure" if cleanup_error == "integrity" else + "post_submit_source_cleanup_unconfirmed" if failure == "release" else + "submission_workspace_cleanup_unconfirmed") + with pytest.raises(ArtifactScratchIntegrityError, match=expected): + await asyncio.wait_for(task, 10) + assert await snapshot(h) == before # running, no final receipt/result/event + assert len(h.store.opens) == 1 + if failure == "release": + assert len(h.preparation._active) == 1 + assert (await h.manager.usage()).reservation_count == 1 + elif failure == "workspace": + assert not h.preparation._active and h.manager._pending_workspaces + else: + assert not h.preparation._active and not h.manager._pending_workspaces + finally: + finish.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + monkeypatch.setattr(h.manager, "release", original_release) + monkeypatch.setattr(h.manager, "_cleanup_workspace_sync", original_workspace) + monkeypatch.setattr(h.inspector, "_projected_tree", original_projection) + for binding in list(h.preparation._active): + await h.preparation.release_prepared_artifact(binding) + await h.preparation.retry_pending_cleanup() + assert not h.preparation._active and not h.manager._pending_workspaces + assert (await h.manager.usage()).reservation_count == 0 + assert list((h.scratch / "workspaces").iterdir()) == [] diff --git a/backend/tests/authorization/setup_finalization/test_catalogue.py b/backend/tests/authorization/setup_finalization/test_catalogue.py index 20d8f3998..bd3361bad 100644 --- a/backend/tests/authorization/setup_finalization/test_catalogue.py +++ b/backend/tests/authorization/setup_finalization/test_catalogue.py @@ -129,6 +129,9 @@ def test_exact_active_action_inventory(): for definition in ACTION_DEFINITIONS if definition.availability is ActionAvailability.ACTIVE } == { + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_SUBMISSION_LIST, ActionId.SUBMISSION_READ, ActionId.SUBMISSION_CHECKER_RUN_LIST, diff --git a/backend/tests/authorization/test_assignment_invalidation_contract.py b/backend/tests/authorization/test_assignment_invalidation_contract.py index 4928dff3b..181b1a6dc 100644 --- a/backend/tests/authorization/test_assignment_invalidation_contract.py +++ b/backend/tests/authorization/test_assignment_invalidation_contract.py @@ -104,13 +104,13 @@ 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} + assert assignment == {"assignment_invalidation_context": resource, "outbox_dispatch_digest": None, "post_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, + "assignment_invalidation_context": None, "outbox_dispatch_digest": digest, "post_submit_prepare_context": None, } assert prepared_fixed_service_bindings(ActionId.ACTOR_PROFILE_READ_SELF, {}, ValueError) == { - "assignment_invalidation_context": None, "outbox_dispatch_digest": None, + "assignment_invalidation_context": None, "outbox_dispatch_digest": None, "post_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 ad312e1c9..112853ae0 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -21,7 +21,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) == 140 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 142 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -100,8 +100,8 @@ 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: 102, - ActionAvailability.PLANNED: 38, + ActionAvailability.ACTIVE: 105, + ActionAvailability.PLANNED: 37, } assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF with pytest.raises(ValueError, match="not active"): @@ -124,7 +124,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 == 75 + assert permission_response.total == 77 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) diff --git a/backend/tests/checkers/execution/material_storage_helpers.py b/backend/tests/checkers/execution/material_storage_helpers.py index 59b80d6f6..2dc107a42 100644 --- a/backend/tests/checkers/execution/material_storage_helpers.py +++ b/backend/tests/checkers/execution/material_storage_helpers.py @@ -1,6 +1,9 @@ """Direct SQL terminal writes, deliberately bypassing the executor's validation.""" from uuid import UUID +import json +from app.adapters.auth import post_submit_execution_authority +from app.modules.checkers.api.execution import FinalizeAuthorityFacts, VerifiedMaterialFacts from sqlalchemy import func, insert, update @@ -14,12 +17,29 @@ from app.modules.outbox.api import OutboxAppendInput -async def write_terminal(session, facts, material): +async def write_terminal(session, facts, material, *, authorized_facts=None): """Stage all otherwise valid members/event/terminal fields in the caller transaction.""" request, result = facts.request, facts.result run = await session.get(CheckerRun, str(result.attempt_id)) + # An explicit earlier valid receipt can accompany deliberately malformed SQL + # in database-guard tests; production never accepts the malformed payload. + authorized = authorized_facts if authorized_facts is not None else facts.model_copy(update={ + "material": VerifiedMaterialFacts.model_validate_json(json.dumps(material)) if material is not None else None, + }) + authority_facts = FinalizeAuthorityFacts( + **authorized.model_dump(), + execute_evidence_id=UUID(run.execute_evidence_id), + ) + async with post_submit_execution_authority(session).prepare_finalization(request) as prepared: + evidence_id = (await prepared.consume(authority_facts)).evidence_id + await stage_terminal(session, facts, material, evidence_id) + + +async def stage_terminal(session, facts, material, evidence_id): + """One direct SQL writer, with the caller's explicit receipt and all guards enabled.""" + request, result = facts.request, facts.result + run = await session.get(CheckerRun, str(result.attempt_id)) classification = classify_result(request, result) - evidence_id = new_record_id() for order, member in enumerate(result.member_results): await session.execute(insert(CheckerResult).values( id=str(new_record_id()), checker_run_id=run.id, task_id=run.task_id, diff --git a/backend/tests/checkers/execution/predecessor_material_helpers.py b/backend/tests/checkers/execution/predecessor_material_helpers.py new file mode 100644 index 000000000..e73e109df --- /dev/null +++ b/backend/tests/checkers/execution/predecessor_material_helpers.py @@ -0,0 +1,8 @@ +"""Pre-0010 persisted receipt shape for the distinct 0008-to-0009 migration proof.""" +from app.core.identifiers import new_record_id +from .material_storage_helpers import stage_terminal + + +async def write_terminal(session, facts, material): + """Represent a predecessor receipt; current schema must reject this unprovable ID.""" + await stage_terminal(session, facts, material, new_record_id()) diff --git a/backend/tests/checkers/execution/predecessor_support.py b/backend/tests/checkers/execution/predecessor_support.py new file mode 100644 index 000000000..53b532f58 --- /dev/null +++ b/backend/tests/checkers/execution/predecessor_support.py @@ -0,0 +1,23 @@ +"""Pre-0010 running-row fixture for material-migration preservation/refusal only.""" +from datetime import timedelta +from app.core.identifiers import new_record_id +from app.modules.checkers.api.execution import ExecutionLease +from app.modules.checkers.execution_repository import ExecutionRepository, reservation + + +async def predecessor_lease(h): + """Seed the exact older schema's persisted shape without simulating live AUTH.""" + async with h.factory() as session, session.begin(): + repo = ExecutionRepository(session) + run = await repo.lock_current(h.request) + now = await repo.now() + lease = ExecutionLease(reservation=reservation(run), lease_id=new_record_id(), + lease_generation=1, expires_at=now + timedelta(seconds=300)) + run.worker_lease_id = str(lease.lease_id) + run.worker_lease_generation = lease.lease_generation + run.worker_lease_expires_at = lease.expires_at + run.execute_evidence_id = str(new_record_id()) + run.started_at = now + run.status = "running" + await session.flush() + return lease diff --git a/backend/tests/checkers/execution/storage_fixture.py b/backend/tests/checkers/execution/storage_fixture.py index 53a73d801..fd9360224 100644 --- a/backend/tests/checkers/execution/storage_fixture.py +++ b/backend/tests/checkers/execution/storage_fixture.py @@ -1,4 +1,4 @@ -"""Closed history with canonical stored ART lineage and controlled phase authority.""" +"""Closed history with canonical stored ART lineage and real fixed-service phase authority.""" import json from uuid import UUID @@ -15,7 +15,7 @@ from app.modules.tasks.models import Submission, WorkstreamTask from tests.checkers.post_submit.support import request as value_request from tests.checkers.post_submit.test_result_contract import result as value_result -from tests.checkers.execution.support import controlled_executor +from tests.checkers.execution.support import live_executor, provision_checker_service from types import SimpleNamespace @@ -73,7 +73,8 @@ async def seed_storage_run(factory, submission_id, *, failures=(), state="comple receipt = await EvaluationCoordinator(session).reserve_current_evaluation(request) if state == "queued": return str(receipt.attempt_id) - executor = controlled_executor(SimpleNamespace(factory=factory, service=None)) + await provision_checker_service(factory) + executor = live_executor(SimpleNamespace(factory=factory, service=None)) lease, replay = await executor._claim(request) assert replay is None if state == "running": diff --git a/backend/tests/checkers/execution/support.py b/backend/tests/checkers/execution/support.py index 778cc1ae8..43eb6f59b 100644 --- a/backend/tests/checkers/execution/support.py +++ b/backend/tests/checkers/execution/support.py @@ -1,89 +1,53 @@ -"""Strict phase-specific participants, never fake AUTH catalogue or audit evidence.""" - -from contextlib import asynccontextmanager +"""Real fixed-service execution fixtures and explicit forbidden phase participants.""" import pytest -from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy import select -from app.adapters.checkers import post_submission_executor -from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id -from app.modules.checkers.api.execution import ( - ExecuteEvidence, - ExecuteFacts, - FinalizeEvidence, - FinalizeFacts, - PreparedExecution, - PreparedFinalization, -) - - -class ForbiddenMaterial: - async def materialize(self, *args): - pytest.fail("denied execution accessed material") - - -def denied_executor(): - # No bind is needed: real production preflight denies before any SQL. - return post_submission_executor( - sessions=async_sessionmaker(), materialization=ForbiddenMaterial() - ) - - -class ControlledExecution(PreparedExecution): - def __init__(self, request): - self.request, self.consumed = request, False - - async def consume(self, facts): - assert type(facts) is ExecuteFacts and facts.request == self.request and not self.consumed - self.consumed = True - return ExecuteEvidence( - evidence_id=new_record_id(), - facts_digest=canonical_json_hash(facts.model_dump(mode="json")), - ) - - -class ControlledFinalization(PreparedFinalization): - def __init__(self, request): - self.request, self.consumed = request, False - - async def consume(self, facts): - assert type(facts) is FinalizeFacts and facts.request == self.request and not self.consumed - self.consumed = True - return FinalizeEvidence( - evidence_id=new_record_id(), - facts_digest=canonical_json_hash(facts.model_dump(mode="json")), - ) - - -class ControlledExecuteAuthority: - async def preflight(self, request): - assert request.evaluation_generation > 0 - - @asynccontextmanager - async def prepare_execution(self, request): - yield ControlledExecution(request) - - -class ControlledFinalizeAuthority: - async def preflight(self, request): - assert request.evaluation_generation > 0 - - @asynccontextmanager - async def prepare_finalization(self, request): - yield ControlledFinalization(request) - - -def controlled_executor(h, *, registry=None, outbox=None): +from app.modules.actors.api import ServiceIdentity +from app.modules.actors.models import ActorProfile, ActorIdentityLink + + +class ForbiddenPostSubmission: + async def evaluate_post_submission(self, request): + pytest.fail("pre-submit-only test entered post-submit execution") + + +def forbidden_post_submission(): + return ForbiddenPostSubmission() + + +async def provision_checker_service(factory): + """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 == ServiceIdentity.CHECKER_POST_SUBMIT.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=ServiceIdentity.CHECKER_POST_SUBMIT.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): + 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, - execute_authority=lambda session: ControlledExecuteAuthority(), - finalize_authority=lambda session: ControlledFinalizeAuthority(), + 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(), outbox=outbox if outbox is not None else outbox_append, ) @@ -94,5 +58,34 @@ async def reserve(h, request=None): async with h.factory() as session, session.begin(): return await EvaluationCoordinator(session).reserve_current_evaluation( - request if request is not None else h.request + request if request is not None else h.request, ) + + +async def material_execution(h): + """Obtain one real committed execution lease for direct ART boundary tests.""" + from app.modules.checkers.api.execution import ExecuteFacts + + await reserve(h) + lease, replay = await live_executor(h)._claim(h.request) + assert replay is None + return ExecuteFacts(request=h.request, lease=lease) + + +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}) + 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}) diff --git a/backend/tests/checkers/execution/test_concurrency.py b/backend/tests/checkers/execution/test_concurrency.py index 0b3a80229..23b13b082 100644 --- a/backend/tests/checkers/execution/test_concurrency.py +++ b/backend/tests/checkers/execution/test_concurrency.py @@ -17,7 +17,7 @@ from tests.checkers.post_submit.support import change_request from tests.checkers.post_submit.test_result_contract import result from tests.post_submit_materialization_helpers import material_fixture -from .support import reserve, controlled_executor +from .support import reserve, live_executor def final_facts(h, lease): @@ -81,7 +81,7 @@ async def test_stale_worker_cannot_finalize_after_takeover( async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) monkeypatch.setattr(execution, "LEASE_SECONDS", 1) old, _ = await executor._claim(h.request) old_facts = final_facts(h, old) @@ -111,7 +111,7 @@ async def test_generation_advance_and_finalize_serialize( ): async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) facts = final_facts(h, lease) successor = change_request( @@ -177,19 +177,20 @@ async def test_evaluation_releases_transaction_before_materialization( tmp_path, isolated_database_env, monkeypatch ): from contextlib import asynccontextmanager - from .support import ControlledExecution, ControlledExecuteAuthority + from app.modules.authorization.post_submit_authorization import PostSubmitExecutionAuthorization import app.modules.checkers.execution as execution async with material_fixture(tmp_path, isolated_database_env) as h: receipt = await reserve(h) active_preparation = [] - class TrackingAuthority(ControlledExecuteAuthority): + class TrackingAuthority(PostSubmitExecutionAuthorization): @asynccontextmanager async def prepare_execution(self, request): active_preparation.append(request.evaluation_request_id) try: - yield ControlledExecution(request) + async with super().prepare_execution(request) as prepared: + yield prepared finally: active_preparation.clear() @@ -227,8 +228,8 @@ async def evaluate(consumer, request, material): return await original(consumer, request, material) monkeypatch.setattr(execution._StructuralConsumer, "evaluate", evaluate) - executor = controlled_executor(h) - executor._execute_authority = lambda session: TrackingAuthority() + executor = live_executor(h) + executor._execute_authority = TrackingAuthority executor._materialization = MaterializationProbe() assert (await executor.evaluate_post_submission(h.request)).outcome == "completed" assert observed == ["before_bytes", "inside_consumer"] @@ -245,7 +246,7 @@ async def test_member_insertion_waits_for_terminal_parent( async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) facts = final_facts(h, lease) entered, release = asyncio.Event(), asyncio.Event() diff --git a/backend/tests/checkers/execution/test_coordination.py b/backend/tests/checkers/execution/test_coordination.py index 8336c11b6..8380ea25c 100644 --- a/backend/tests/checkers/execution/test_coordination.py +++ b/backend/tests/checkers/execution/test_coordination.py @@ -9,7 +9,7 @@ from app.modules.checkers.models import CheckerRun, CheckerSubmissionFence from tests.checkers.post_submit.support import change_request from tests.post_submit_materialization_helpers import material_fixture -from .support import reserve, controlled_executor +from .support import reserve, live_executor async def test_reservation_replay_rejects_changed_envelope(tmp_path, isolated_database_env): @@ -52,7 +52,7 @@ async def test_reservation_rollback_and_exact_successor(tmp_path, isolated_datab # Replaying old reservation does not restore it as current. assert await reserve(h) == first with pytest.raises(CheckerExecutionUnavailable, match="current_request"): - await controlled_executor(h).evaluate_post_submission(h.request) + await live_executor(h).evaluate_post_submission(h.request) @pytest.mark.parametrize( @@ -61,7 +61,7 @@ async def test_reservation_rollback_and_exact_successor(tmp_path, isolated_datab async def test_current_result_conceals_foreign_lineage(tmp_path, isolated_database_env, field): async with material_fixture(tmp_path / "first", isolated_database_env) as first: await reserve(first) - await controlled_executor(first).evaluate_post_submission(first.request) + await live_executor(first).evaluate_post_submission(first.request) async with material_fixture( tmp_path / "foreign", isolated_database_env, @@ -69,7 +69,7 @@ async def test_current_result_conceals_foreign_lineage(tmp_path, isolated_databa storage_settings=first.settings, ) as foreign: await reserve(foreign) - await controlled_executor(foreign).evaluate_post_submission(foreign.request) + await live_executor(foreign).evaluate_post_submission(foreign.request) if field == "project_id": # A project substitution must carry a matching valid policy/context, # so validation succeeds and the repository owns the isolation proof. diff --git a/backend/tests/checkers/execution/test_execution.py b/backend/tests/checkers/execution/test_execution.py index 3a2712f81..8df2d555a 100644 --- a/backend/tests/checkers/execution/test_execution.py +++ b/backend/tests/checkers/execution/test_execution.py @@ -7,9 +7,9 @@ from app.modules.checkers.execution_coordination import EvaluationCoordinator from app.modules.checkers.models import CheckerRun, CheckerResult from app.modules.outbox.models import OutboxEvent -from tests.checkers.post_submit.support import request from tests.post_submit_materialization_helpers import material_fixture -from .support import controlled_executor, denied_executor, reserve +from .support import live_executor, reserve, service_link_state +from app.modules.actors.api import ServiceIdentity @pytest.fixture @@ -29,9 +29,12 @@ async def read_clock(repo): monkeypatch.setattr(ExecutionRepository, "now", read_clock) -async def test_production_denies_before_access(): - with pytest.raises(CheckerExecutionUnavailable, match="post_submit_execution_unavailable"): - await denied_executor().evaluate_post_submission(request()) +async def test_missing_checker_principal_denies_before_access(tmp_path, isolated_database_env): + async with material_fixture(tmp_path, isolated_database_env, provision_checker=False) as h: + await reserve(h) + with pytest.raises(CheckerExecutionUnavailable, match="authority_unavailable"): + await live_executor(h).evaluate_post_submission(h.request) + assert h.store.opens == [] and not h.preparation._active @pytest.mark.parametrize("provider", ["local", "minio"]) @@ -44,7 +47,7 @@ async def test_verified_material_execution_and_replay( await provision_minio_bucket.__wrapped__() async with material_fixture(tmp_path, isolated_database_env, provider=provider) as h: reservation = await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) result = await executor.evaluate_post_submission(h.request) assert result.outcome == "completed" assert ( @@ -59,20 +62,17 @@ async def test_verified_material_execution_and_replay( async with h.factory() as session, session.begin(): current = await EvaluationCoordinator(session).read_current_result(h.request) assert current.result == result - assert len(h.authority.selections) == 1 - authorized_request, selected = h.authority.selections[0] - assert authorized_request == h.request run = await session.get(CheckerRun, str(result.attempt_id)) assert run.material_custody == { - "submission_id": str(selected.submission.submission_id), - "submission_version": selected.submission.submission_version, - "admission_id": str(selected.submission.admission_id), - "binding_id": str(selected.submission.binding_id), - "content_id": str(selected.submission.content_id), - "replica_id": str(selected.replica_id), - "content_sha256": selected.sha256, - "byte_count": selected.byte_count, - "semantic_manifest_sha256": selected.semantic_manifest_sha256, + "submission_id": str(h.created.submission_id), + "submission_version": h.created.submission_version, + "admission_id": str(h.created.admission_id), + "binding_id": str(h.created.artifact_binding_id), + "content_id": str(h.created.artifact_content_id), + "replica_id": str(h.replica_id), + "content_sha256": h.request.content_sha256, + "byte_count": len(h.data), + "semantic_manifest_sha256": h.manifest.sha256, } assert current.routing_recommendation in {"allow_review", "needs_revision"} rows = list( @@ -101,7 +101,7 @@ async def test_infrastructure_failure_is_terminal(tmp_path, isolated_database_en async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h, registry=CheckerRegistry()) + 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" @@ -127,7 +127,7 @@ async def test_action_authority_is_not_interchangeable( tmp_path, isolated_database_env, substitution ): from contextlib import asynccontextmanager - from app.core.hashing import canonical_json_hash + from app.modules.checkers.api.execution import execution_authority_digest from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import ( PreparedExecution, @@ -136,11 +136,11 @@ async def test_action_authority_is_not_interchangeable( FinalizeEvidence, ) from .test_concurrency import final_facts - from .support import ControlledFinalizeAuthority + from app.modules.authorization.post_submit_authorization import PostSubmitExecutionAuthorization async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) facts = final_facts(h, lease) @@ -148,19 +148,23 @@ async def test_action_authority_is_not_interchangeable( class WrongPrepared( PreparedExecution if substitution == "prepared" else PreparedFinalization ): + async def validate_replay(self, facts, evidence_id): + pytest.fail("wrong-phase receipt reached replay") + async def consume(self, value): receipt = FinalizeEvidence if substitution == "prepared" else ExecuteEvidence return receipt( evidence_id=new_record_id(), - facts_digest=canonical_json_hash(value.model_dump(mode="json")), + facts_digest=execution_authority_digest(value), ) - class WrongAuthority(ControlledFinalizeAuthority): + class WrongAuthority(PostSubmitExecutionAuthorization): @asynccontextmanager async def prepare_finalization(self, request): yield WrongPrepared() - executor._finalize_authority = lambda session: WrongAuthority() + original_authority = executor._finalize_authority + executor._finalize_authority = WrongAuthority with pytest.raises( CheckerExecutionUnavailable, match="authority_unavailable|action_evidence_unavailable" ): @@ -169,7 +173,7 @@ async def prepare_finalization(self, request): run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) assert run.status == "running" and run.result_json is None assert await session.scalar(select(func.count()).select_from(CheckerResult)) == 0 - executor._finalize_authority = lambda session: ControlledFinalizeAuthority() + executor._finalize_authority = original_authority assert await executor.finalize(facts) == facts.result @@ -185,7 +189,7 @@ async def test_zero_output_finalization(tmp_path, isolated_database_env): async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) selector = CheckerOutputSelector( evaluation=h.request, @@ -226,9 +230,12 @@ async def test_finalization_outbox_failure_rolls_back(tmp_path, isolated_databas async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) facts = final_facts(h, lease) + from tests.authorization.post_submit.test_receipt_custody import snapshot + from app.modules.tasks.models import AuditEvent + before = await snapshot(h) async with h.factory() as session, session.begin(): await session.execute( text("""create function test_checker_outbox_failure() returns trigger language plpgsql as $$ @@ -259,6 +266,11 @@ 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" return await outbox_append(self.session).append(value) executor._outbox = ObservingAppend @@ -266,6 +278,7 @@ async def append(self, value): with pytest.raises(OutboxPersistenceError): await executor.finalize(facts) assert observations == [True] + assert await snapshot(h) == before async with h.factory() as session: run = await session.get(CheckerRun, str(lease.reservation.attempt_id)) assert ( @@ -297,8 +310,6 @@ async def append(self, value): async def test_unreadable_stored_bytes_terminalize_and_replay( tmp_path, isolated_database_env, monkeypatch, damage ): - from app.modules.checkers.execution_authority import DenyFinalizationAuthority - from .support import ControlledFinalizeAuthority from tests.test_local_artifact_store import object_path async with material_fixture(tmp_path, isolated_database_env) as h: @@ -312,29 +323,30 @@ 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 = controlled_executor(h) + executor = live_executor(h) async def forbidden_checker(*args, **kwargs): pytest.fail("unverified stored bytes reached a checker") monkeypatch.setattr(executor._registry, "run", forbidden_checker) # The byte failure cannot bypass the separate finalization authority. - executor._finalize_authority = lambda session: DenyFinalizationAuthority() pending = [] finalize = executor.finalize async def retain_finalization(facts): pending.append(facts) + await service_link_state(h.factory, ServiceIdentity.CHECKER_POST_SUBMIT, active=False) return await finalize(facts) monkeypatch.setattr(executor, "finalize", retain_finalization) - with pytest.raises(CheckerExecutionUnavailable, match="finalization_unavailable"): + with pytest.raises(CheckerExecutionUnavailable, match="authority_unavailable"): await executor.evaluate_post_submission(h.request) async with h.factory() as session: run = await session.get(CheckerRun, str(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 - executor._finalize_authority = lambda session: ControlledFinalizeAuthority() + await service_link_state(h.factory, ServiceIdentity.CHECKER_POST_SUBMIT, active=True) + monkeypatch.setattr(executor, "finalize", finalize) assert len(pending) == 1 result = await finalize(pending[0]) assert result.outcome == "infrastructure_failed" @@ -363,7 +375,6 @@ async def test_nonrecordable_material_failure_leaves_attempt_recoverable( tmp_path, isolated_database_env, monkeypatch, failure ): import asyncio - from app.modules.artifacts.post_submit_materialization import DenyPostSubmissionMaterializationAuthority from app.modules.artifacts.preparation import ArtifactScratchIntegrityError from app.modules.checkers.api.materialization import ( PostSubmissionMaterializationUnavailable, PostSubmissionMaterializationFailure, @@ -377,9 +388,9 @@ async def test_nonrecordable_material_failure_leaves_attempt_recoverable( update={"manifest": ()}, )) reservation = await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) if failure == "authority": - h.service._authority = DenyPostSubmissionMaterializationAuthority() + await service_link_state(h.factory, ServiceIdentity.ARTIFACT_MATERIALIZER, active=False) expected = PostSubmissionMaterializationUnavailable elif failure == "cleanup": import sys diff --git a/backend/tests/checkers/execution/test_material_lineage.py b/backend/tests/checkers/execution/test_material_lineage.py index 70fb03dff..70ed28f7d 100644 --- a/backend/tests/checkers/execution/test_material_lineage.py +++ b/backend/tests/checkers/execution/test_material_lineage.py @@ -1,14 +1,14 @@ """Canonical ART membership is enforced at commit, including retained failed runs.""" import pytest -from sqlalchemy import select, func +from sqlalchemy import select, func, text 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.outbox.models import OutboxEvent from tests.post_submit_materialization_helpers import material_fixture -from .support import controlled_executor, reserve +from .support import live_executor, reserve from .test_concurrency import final_facts from .material_storage_helpers import write_terminal @@ -28,7 +28,7 @@ async def test_foreign_canonical_material_is_rejected_at_commit(tmp_path, isolat async with material_fixture(tmp_path / "foreign", isolated_database_env, storage_settings=h.settings, provision_services=False) as foreign: await reserve(h) - lease, _ = await controlled_executor(h)._claim(h.request) + lease, _ = await live_executor(h)._claim(h.request) facts = terminal_facts(h, lease, outcome) canonical = facts.material.model_dump(mode="json") substitutions = { @@ -46,11 +46,12 @@ async def test_foreign_canonical_material_is_rejected_at_commit(tmp_path, isolat for field, value in substitutions.items(): assert canonical.get(field) != value async with h.factory() as session: - await write_terminal(session, facts, canonical | {field: value}) - # Only commit invokes the deferred guard; preceding member and - # event inserts/terminal UPDATE all completed successfully. + await write_terminal(session, facts, canonical | {field: value}, + authorized_facts=facts if field in {"submission_version", "byte_count", "unexpected"} else None) + # Force this deferred guard before receipt validation so its + # rejection cannot be substituted by a different constraint. with pytest.raises(IntegrityError, match="checker material canonical ART lineage mismatch"): - await session.commit() + await session.execute(text("SET CONSTRAINTS public.checker_material_lineage IMMEDIATE")) await session.rollback() async with h.factory() as session: run = await session.get(CheckerRun, str(facts.result.attempt_id)) @@ -74,7 +75,7 @@ async def test_temporary_tables_cannot_replace_canonical_material( async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - lease, _ = await controlled_executor(h)._claim(h.request) + lease, _ = await live_executor(h)._claim(h.request) facts = terminal_facts(h, lease, outcome) canonical = facts.material.model_dump(mode="json") digest = canonical["semantic_manifest_sha256"] @@ -139,7 +140,7 @@ async def test_canonical_validator_binds_numeric_version_argument(tmp_path, isol async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - lease, _ = await controlled_executor(h)._claim(h.request) + lease, _ = await live_executor(h)._claim(h.request) facts = final_facts(h, lease) parameters = { "project": h.request.project_id, diff --git a/backend/tests/checkers/execution/test_material_migration.py b/backend/tests/checkers/execution/test_material_migration.py index f9444bd49..02d83afe0 100644 --- a/backend/tests/checkers/execution/test_material_migration.py +++ b/backend/tests/checkers/execution/test_material_migration.py @@ -16,8 +16,9 @@ from app.modules.checkers.post_submit_contracts import make_post_submit_result from tests.checkers.post_submit.support import change_request -from .material_storage_helpers import write_terminal -from .support import controlled_executor, reserve +from .predecessor_material_helpers import write_terminal +from .support import reserve +from .predecessor_support import predecessor_lease from .test_material_lineage import terminal_facts pytestmark = pytest.mark.postgres_schema_contract @@ -52,9 +53,9 @@ async def retained_snapshot(factory): async def test_retained_material_upgrade(tmp_path, isolated_database_env, migration_lock, outcome, valid): with migration_lock(): await predecessor_database(isolated_database_env) - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture(tmp_path, isolated_database_env, provision_checker=False) as h: await reserve(h) - lease, _ = await controlled_executor(h)._claim(h.request) + lease = await predecessor_lease(h) facts = terminal_facts(h, lease, outcome) material = facts.material.model_dump(mode="json") if not valid: @@ -70,7 +71,7 @@ async def test_retained_material_upgrade(tmp_path, isolated_database_env, migrat successor.request = change_request(h.request, evaluation_generation=2, evaluation_request_id=new_record_id()) await reserve(successor) - next_lease, _ = await controlled_executor(successor)._claim(successor.request) + next_lease = await predecessor_lease(successor) empty = terminal_facts(successor, next_lease, "infrastructure_failed") body = empty.result.model_dump(exclude={"result_digest"}) body["infrastructure_failure_code"] = "material_unavailable" @@ -84,13 +85,13 @@ async def test_retained_material_upgrade(tmp_path, isolated_database_env, migrat before = await retained_snapshot(h.factory) assert before[1] == "0008_checker_execution" if valid: - await asyncio.to_thread(command.upgrade, _config(), "head") + await asyncio.to_thread(command.upgrade, _config(), "0009_checker_material_lineage") after = await retained_snapshot(h.factory) assert after[0] == before[0] assert after[1] == "0009_checker_material_lineage" else: with pytest.raises(IntegrityError, match="retained checker material lacks canonical ART lineage"): - await asyncio.to_thread(command.upgrade, _config(), "head") + await asyncio.to_thread(command.upgrade, _config(), "0009_checker_material_lineage") assert await retained_snapshot(h.factory) == before @@ -102,9 +103,9 @@ async def test_upgrade_excludes_writer_until_guard_is_installed( with migration_lock(): await predecessor_database(isolated_database_env) - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture(tmp_path, isolated_database_env, provision_checker=False) as h: await reserve(h) - lease, _ = await controlled_executor(h)._claim(h.request) + lease = await predecessor_lease(h) facts = terminal_facts(h, lease, "completed") material = facts.material.model_dump(mode="json") | {"admission_id": str(h.request.submission_id)} scanned, resume = threading.Event(), threading.Event() @@ -118,7 +119,7 @@ def paused_execute(operations, sql, *args, **kwargs): return result monkeypatch.setattr(Operations, "execute", paused_execute) - migration = asyncio.create_task(asyncio.to_thread(command.upgrade, _config(), "head")) + migration = asyncio.create_task(asyncio.to_thread(command.upgrade, _config(), "0009_checker_material_lineage")) writer = None pid = asyncio.Queue() diff --git a/backend/tests/checkers/execution/test_migration.py b/backend/tests/checkers/execution/test_migration.py index 81963cefb..cd369da8b 100644 --- a/backend/tests/checkers/execution/test_migration.py +++ b/backend/tests/checkers/execution/test_migration.py @@ -28,7 +28,7 @@ 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") - async with material_fixture(tmp_path, isolated_database_env) as h: + async with material_fixture(tmp_path, isolated_database_env, provision_checker=False) 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"), @@ -144,7 +144,7 @@ async def test_empty_database_installs_execution_custody(isolated_database_env, try: assert ( await conn.fetchval("select version_num from alembic_version") - == "0009_checker_material_lineage" + == "0010_post_submit_authority" ) assert await conn.fetchval("select count(*) from checker_submission_fences") == 0 columns = set( diff --git a/backend/tests/checkers/execution/test_storage.py b/backend/tests/checkers/execution/test_storage.py index d5b889eac..00e54438f 100644 --- a/backend/tests/checkers/execution/test_storage.py +++ b/backend/tests/checkers/execution/test_storage.py @@ -11,7 +11,7 @@ from app.modules.checkers.execution_repository import request_text from tests.checkers.post_submit.support import change_request from tests.post_submit_materialization_helpers import material_fixture -from .support import reserve, controlled_executor +from .support import reserve, live_executor from .test_concurrency import final_facts @@ -61,7 +61,7 @@ async def test_terminal_member_and_routing_custody(tmp_path, isolated_database_e async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) facts = final_facts(h, lease) members = list(facts.result.member_results) @@ -124,7 +124,7 @@ async def test_execution_custody_rejects_mutation(tmp_path, isolated_database_en async def test_unfinished_members_cannot_commit(tmp_path, isolated_database_env): async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - lease, _ = await controlled_executor(h)._claim(h.request) + lease, _ = await live_executor(h)._claim(h.request) async with h.factory() as session: from app.modules.checkers.execution_repository import ExecutionRepository @@ -145,7 +145,7 @@ async def test_member_shape_and_complete_set_enforced_in_database( async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) facts = final_facts(h, lease) first = facts.result.member_results[0] @@ -211,11 +211,12 @@ async def test_consistently_short_result_cannot_omit_selected_policy_member( from app.modules.outbox.models import OutboxEvent from app.modules.checkers.api.execution import COMPLETION_EVENT from sqlalchemy import func - import app.modules.checkers.execution as execution + from sqlalchemy import text + from . import material_storage_helpers async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - executor = controlled_executor(h) + executor = live_executor(h) lease, _ = await executor._claim(h.request) valid = final_facts(h, lease) assert len(valid.result.member_results) > 1 @@ -227,15 +228,20 @@ async def test_consistently_short_result_cannot_omit_selected_policy_member( # Simulate a faulty validator/compiler accepting a self-consistent short # result. Persisted members, counts, result digest and event all agree; # only comparison with the locked policy's selected set can reject it. - canonical = execution.classify_result + canonical = material_storage_helpers.classify_result def faulty_classifier(request, result): assert request == h.request and result == short return ResultClassification("allow_review", len(short.member_results), 0, 0, 0) - monkeypatch.setattr(execution, "classify_result", faulty_classifier) - with pytest.raises(IntegrityError, match="checker completed custody or routing invalid"): - await executor.finalize(facts) + monkeypatch.setattr(material_storage_helpers, "classify_result", faulty_classifier) + async with h.factory() as session: + await material_storage_helpers.write_terminal( + session, facts, facts.material.model_dump(mode="json"), authorized_facts=valid, + ) + with pytest.raises(IntegrityError, match="checker completed custody or routing invalid"): + await session.execute(text("SET CONSTRAINTS public.checker_terminal_custody IMMEDIATE")) + await session.rollback() async with h.factory() as session: assert ( await session.get(CheckerRun, str(lease.reservation.attempt_id)) @@ -249,19 +255,19 @@ def faulty_classifier(request, result): ) == 0 ) - monkeypatch.setattr(execution, "classify_result", canonical) + monkeypatch.setattr(material_storage_helpers, "classify_result", 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 + 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 async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) - lease, _ = await controlled_executor(h)._claim(h.request) + lease, _ = await live_executor(h)._claim(h.request) result = make_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, @@ -275,7 +281,6 @@ async def test_infrastructure_failure_code_is_closed_in_database(tmp_path, isola # Valid ART lineage isolates the terminal-shape guard: material # must be absent when the declared failure is material_unavailable. ("material_unavailable", final_facts(h, lease).material.model_dump(mode="json")), - ("material_unavailable", None), ): candidate = body | {"infrastructure_failure_code": code} statement = update(CheckerRun).where(CheckerRun.id == str(result.attempt_id)).values( @@ -285,16 +290,14 @@ async def test_infrastructure_failure_code_is_closed_in_database(tmp_path, isola finalize_evidence_id=str(new_record_id()), completed_at=func.clock_timestamp(), outcome_source="auto_checker", routing_recommendation="not_evaluated", ) - if code == "invented_failure" or custody is not None: - with pytest.raises(IntegrityError, match="infrastructure terminal shape invalid"): - await session.execute(statement) - await session.commit() - await session.rollback() - run = await session.get(CheckerRun, str(result.attempt_id)) - assert run.status == "running" and run.result_json is None - else: + with pytest.raises(IntegrityError, match="infrastructure terminal shape invalid"): await session.execute(statement) - await session.commit() + await session.execute(text("SET CONSTRAINTS public.checker_terminal_custody IMMEDIATE")) + await session.rollback() + run = await session.get(CheckerRun, str(result.attempt_id)) + assert run.status == "running" and run.result_json is None + valid = final_facts(h, lease).model_copy(update={"result": result, "material": None}) + assert await live_executor(h).finalize(valid) == result async with h.factory() as session: run = await session.get(CheckerRun, str(result.attempt_id)) assert run.failure_code == "material_unavailable" and run.status == "infrastructure_failed" diff --git a/backend/tests/checkers/post_submit/test_request.py b/backend/tests/checkers/post_submit/test_request.py index 23b9530dd..7c818f30b 100644 --- a/backend/tests/checkers/post_submit/test_request.py +++ b/backend/tests/checkers/post_submit/test_request.py @@ -1,8 +1,7 @@ """Identity consistency, complete valid denial control, and absence of owner authority.""" -from app.modules.checkers.api.execution import CheckerExecutionUnavailable - -from tests.checkers.execution.support import denied_executor +from app.adapters.checkers import post_submission_executor +from sqlalchemy.ext.asyncio import async_sessionmaker from app.core.identifiers import new_record_id @@ -55,22 +54,6 @@ def test_coherent_foreign_facts_are_not_an_authorization_proof(): assert not hasattr(other, "is_current") -async def test_post_port_unavailable(monkeypatch): - source = request() - # A complete valid request reaches the precise unavailable guard. - from app.modules.checkers import post_submit_implementations - - monkeypatch.setattr( - post_submit_implementations, - "evaluate_registered_structural_member", - lambda *args: pytest.fail("unavailable phase executed a member"), - ) - with pytest.raises( - CheckerExecutionUnavailable, match="^post_submit_execution_unavailable$" - ): - await denied_executor().evaluate_post_submission(source) - - def test_fact_hashes_are_derived_and_nested_values_are_immutable(): source = request() with pytest.raises(ValueError, match="derived"): @@ -91,8 +74,8 @@ def test_strict_request_numbers(field, invalid): change_request(request(), **{field: invalid}) -async def test_unavailable_port_revalidates_unsafe_constructed_instances(): +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 denied_executor().evaluate_post_submission(forged) + await post_submission_executor(sessions=async_sessionmaker(), materialization=None).evaluate_post_submission(forged) diff --git a/backend/tests/checkers/test_phase_service.py b/backend/tests/checkers/test_phase_service.py index c499c79df..fe3dfdf19 100644 --- a/backend/tests/checkers/test_phase_service.py +++ b/backend/tests/checkers/test_phase_service.py @@ -2,7 +2,7 @@ from app.modules.checkers.api.execution import CheckerExecutionUnavailable -from tests.checkers.execution.support import denied_executor +from tests.checkers.execution.support import forbidden_post_submission import inspect from types import SimpleNamespace @@ -21,7 +21,7 @@ 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 denied_executor(), + post_submission=post if post is not None else forbidden_post_submission(), ) @@ -66,10 +66,15 @@ async def test_pre_phase_rejects_authorization_handle_and_propagates_owner_failu @pytest.mark.asyncio -async def test_valid_post_phase_stays_unavailable_without_invoking_pre_owner(): +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).evaluate_post_submission(request()) + 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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 777df185b..b70a6ced8 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 = "2ef1e48e141c53a581ec791da3e50fec33cefac001ccb5ddf3ea073db0ec87a0" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "cd42c4edbe412cee9b5f139732e55359a4381337d924ed35af78992d34811e79" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/post_submit_materialization_helpers.py b/backend/tests/post_submit_materialization_helpers.py index d2a2a5bb5..b5034436c 100644 --- a/backend/tests/post_submit_materialization_helpers.py +++ b/backend/tests/post_submit_materialization_helpers.py @@ -11,14 +11,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from app.adapters.artifacts import create_artifact_store_bootstrap +from app.adapters.artifacts import create_artifact_store_bootstrap, post_submission_materialization from app.adapters.tasks import submitted_bundle_port from app.api.deps.authorization import compose_hidden_submission_creation_command from app.core.identifiers import new_record_id from app.interfaces.artifacts import ArtifactStoreNamespaceClaim from app.modules.artifacts.api import SubmissionBundlePreparationRequest from app.modules.artifacts.models import SubmissionBundleAdmission -from app.modules.artifacts.post_submit_materialization import PostSubmissionMaterializer 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 @@ -43,20 +42,6 @@ from tests.test_default_pre_submit_execution import _archive, _bytes -class ControlledAuthority: - """Test-only seam, not a production service admission or generation store.""" - - def __init__(self): - self.requests = [] - self.selections = [] - - async def preflight(self, request): - self.requests.append(request) - - async def authorize(self, request, selection): - self.selections.append((request, selection)) - - class CountedStore: def __init__(self, store): self.wrapped, self.opens = store, [] @@ -83,9 +68,18 @@ def archive_with_modes(evidence_path, project_id): return archive_bytes.getvalue() +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_checker_service + if artifacts: + await _seed_services(factory) + if checker: + await provision_checker_service(factory) + + @asynccontextmanager async def material_fixture(tmp_path, database_url, *, provider="local", scratch_limits=None, - storage_settings=None, provision_services=True): + storage_settings=None, provision_services=True, provision_checker=True): engine = create_async_engine(database_url) factory = async_sessionmaker(engine, expire_on_commit=False) if storage_settings is not None: @@ -108,8 +102,7 @@ async def material_fixture(tmp_path, database_url, *, provider="local", scratch_ try: plan, policy = await approved_pre_submit_fixture(factory, namespace, guide_version="v1") context = _context() - if provision_services: - await _seed_services(factory) + 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) @@ -167,17 +160,17 @@ async def material_fixture(tmp_path, database_url, *, provider="local", scratch_ observed_context=ObservedPostSubmitContext(**asdict(facts.context)), ), ) - authority, counted = ControlledAuthority(), CountedStore(store) + counted = CountedStore(store) preparation = ArtifactPreparationService(manager) - service = PostSubmissionMaterializer( - sessions=factory, tasks=submitted_bundle_port, store=counted, namespace=namespace, - preparation=preparation, inspector=inspector, authority=authority, + service = post_submission_materialization( + 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, settings=settings, store=counted, namespace=namespace, preparation=preparation, - manager=manager, inspector=inspector, authority=authority, + manager=manager, inspector=inspector, scratch=tmp_path / "post-scratch") finally: manager.close() diff --git a/backend/tests/retained_material_fixtures.py b/backend/tests/retained_material_fixtures.py index 8950a619f..87c13e077 100644 --- a/backend/tests/retained_material_fixtures.py +++ b/backend/tests/retained_material_fixtures.py @@ -34,7 +34,7 @@ from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository from app.modules.projects.models import EffectiveProjectSubmissionArtifactPolicy from app.modules.tasks.repository import TaskRepository -from tests.checkers.execution.support import denied_executor +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 @@ -128,7 +128,7 @@ async def retained_admission(factory, task, assignment, link, packet, predecesso async def runtime(): yield SubmissionBundlePreparationRuntime( preparation, inspector, catalogue, evidence._materialization, evidence, - CheckerPhaseService(pre_submission=evidence, post_submission=denied_executor()), + 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, diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 519752eed..71bfafb51 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, "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, "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] diff --git a/backend/tests/test_approved_guide_intake.py b/backend/tests/test_approved_guide_intake.py index 7af71a1c3..fd255888b 100644 --- a/backend/tests/test_approved_guide_intake.py +++ b/backend/tests/test_approved_guide_intake.py @@ -186,7 +186,7 @@ async def test_command_holds_authorized_context_before_final_handoff(tmp_path, i PreparedSubmissionBundlePreparationCommand, SubmissionBundlePreparationRuntime, SubmissionBundleDurablePutService, SubmissionBundleDurablePutResult, ) - from tests.checkers.execution.support import denied_executor + 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 @@ -249,7 +249,7 @@ async def runtime(): preparation=harness.preparation, inspector=harness.inspector, catalogue=harness.catalogue, materialization=workflow._materialization, evidence=workflow, checker_service=CheckerPhaseService( - pre_submission=workflow, post_submission=denied_executor()), + pre_submission=workflow, post_submission=forbidden_post_submission()), durable_put=SimpleNamespace(admit_in_transaction=final_handoff, publish_after_commit=stop_before_provider), ) diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index 3af193835..92bc0ec11 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -581,6 +581,7 @@ def test_checker_materialization_contract_has_one_canonical_consumer_owner() -> node.name: node for node in tree.body if isinstance(node, ast.ClassDef) } assert set(classes) == { + "MaterializationFacts", "PreparedMaterialization", "MaterializationAuthorityPort", "PostSubmissionMaterializationUnavailable", "PostSubmissionMaterializationFailure", "SubmissionMaterialEntry", @@ -611,6 +612,8 @@ def test_checker_custody_ports_have_exact_owner_and_art_consumers() -> None: "app/modules/checkers/execution_coordination.py", }, "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", diff --git a/backend/tests/test_artifact_preparation.py b/backend/tests/test_artifact_preparation.py index 2098c7a42..246ebba3f 100644 --- a/backend/tests/test_artifact_preparation.py +++ b/backend/tests/test_artifact_preparation.py @@ -2447,11 +2447,11 @@ async def delayed_release(reservation: object) -> None: @pytest.mark.asyncio -async def test_cancelled_close_preserves_cancellation_when_cleanup_fails( +async def test_cancelled_close_propagates_cleanup_failure_for_retry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Keep retryable ownership while cancellation remains the caller result.""" + """Keep retryable ownership and expose the cleanup failure despite cancellation.""" manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=preparation_limits()) service = ArtifactPreparationService(manager) prepared = await service.prepare(byte_stream(b"data"), media_type="text/plain") @@ -2471,7 +2471,7 @@ async def delayed_failure(_: object) -> None: await asyncio.sleep(0) close_task.cancel() finish_release.set() - with pytest.raises(asyncio.CancelledError): + with pytest.raises(OSError, match="injected release failure"): await close_task assert not prepared._closed @@ -2489,14 +2489,15 @@ async def delayed_failure(_: object) -> None: async def test_second_pass_deadline_is_enforced(tmp_path: Path) -> None: """Keep the provider-consumption deadline shorter than reservation TTL.""" limits = preparation_limits( - reservation_ttl_seconds=1.0, - total_deadline_seconds=0.5, - cleanup_margin_seconds=0.1, + reservation_ttl_seconds=30.0, + total_deadline_seconds=20.0, + cleanup_margin_seconds=5.0, ) manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=limits) service = ArtifactPreparationService(manager) prepared = await service.prepare(byte_stream(b"data"), media_type="text/plain") - await asyncio.sleep(0.55) + # Expire only the second-pass window after the first pass has succeeded. + service._active[prepared._binding].deadline = asyncio.get_running_loop().time() - 1 async with prepared as source: with pytest.raises(ArtifactPreparationDeadlineError): _ = [chunk async for chunk in source.stream()] @@ -2547,3 +2548,52 @@ async def test_client_commitment_shape_is_rejected_before_reservation( await service.prepare(byte_stream(b"data"), **kwargs) assert (await manager.usage()).reservation_count == 0 manager.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancellation", ["caller", "deadline"]) +async def test_submission_processing_preserves_cancellation_over_aborted_callback_error(tmp_path, monkeypatch, cancellation): + """The shared pre/post owner distinguishes callback errors from its cleanup.""" + manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=preparation_limits()) + service = ArtifactPreparationService(manager) + prepared = await service.prepare(byte_stream(b"data"), media_type="text/plain") + entered, aborted = asyncio.Event(), asyncio.Event() + class Processor: + async def process(self, reader, workspace): + entered.set() + await aborted.wait() + raise RuntimeError("processor error after abort") + def abort(self): + aborted.set() + deadlines, original_timeout = [], asyncio.timeout + def controlled_timeout_at(deadline): + timer = original_timeout(None) + deadlines.append(timer) + return timer + monkeypatch.setattr(asyncio, "timeout_at", controlled_timeout_at) + task = asyncio.create_task(service._process_prepared_submission( + prepared, Processor(), reserved_bytes=1, maximum_entries=1, + )) + try: + await asyncio.wait_for(entered.wait(), 1) + if cancellation == "caller": + task.cancel() + expected = asyncio.CancelledError + else: + assert len(deadlines) == 1 + deadlines[0].reschedule(asyncio.get_running_loop().time()) + expected = ArtifactPreparationDeadlineError + with pytest.raises(expected): + await asyncio.wait_for(task, 1) + assert aborted.is_set() + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + assert not manager._pending_workspaces + assert len(service._active) == 1 + finally: + aborted.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await prepared.close() + assert (await manager.usage()).reservation_count == 0 + manager.close() diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index d8dcdd62d..121ea8c63 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -74,10 +74,9 @@ async def audit_factory(audit_database_env: str): ) ) await connection.execute(delete(AuditEvent).where(AuditEvent.id.in_(created))) + await connection.execute(text("set constraints all immediate")) await connection.execute( - text( - "alter table audit_events enable trigger audit_events_reject_update_delete" - ) + text("alter table audit_events enable trigger audit_events_reject_update_delete") ) await engine.dispose() diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index d45e4d04c..fb2328e5e 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2204,7 +2204,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())) == 25 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 27 assert FUTURE_INTENT_REQUIRED_ACTIONS == { ActionId.REVIEW_FINDING_EVIDENCE_INGEST, ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, @@ -2277,6 +2277,9 @@ def test_submission_artifact_policy_draft_actions_have_exact_child_owners() -> N ActionAvailability.ACTIVE, ) active_internal = { + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionId.OUTBOX_DISPATCH, ActionId.ARTIFACT_VERIFICATION_EXECUTE, @@ -6056,10 +6059,8 @@ async def mutate_after_prepare(): async with authorization_factory() as cleanup: await cleanup.execute(text("alter table audit_events disable trigger user")) - await cleanup.execute( - text("delete from audit_events where id=:id"), - {"id": str(decision.decision_id)}, - ) + await cleanup.execute(text("delete from audit_events where id=:id"), {"id": str(decision.decision_id)}) + await cleanup.execute(text("set constraints all immediate")) await cleanup.execute(text("alter table audit_events enable trigger user")) await cleanup.execute(text("alter table actor_identity_links disable trigger user")) await cleanup.execute(text("alter table actor_profiles disable trigger user")) @@ -6348,10 +6349,9 @@ async def find_and_pause(*args, **kwargs): ), {"target": actor_ids[0], "mutator": actor_ids[1]}, ) + await cleanup.execute(text("set constraints all immediate")) await cleanup.execute(text("alter table audit_events enable trigger user")) - await cleanup.execute( - text("alter table authority_idempotency_records disable trigger user") - ) + await cleanup.execute(text("alter table authority_idempotency_records disable trigger user")) await cleanup.execute( text( "delete from authority_idempotency_records where actor_ref in (:target, :mutator)" @@ -9141,13 +9141,9 @@ async def authorization_factory(authorization_database_env: str): text("lock table authority_idempotency_records in access exclusive mode") ) await connection.execute(text("lock table audit_events in access exclusive mode")) + await connection.execute(text("alter table audit_events disable trigger audit_events_reject_update_delete")) await connection.execute( - text("alter table audit_events disable trigger audit_events_reject_update_delete") - ) - await connection.execute( - text( - "alter table authority_idempotency_records disable trigger authority_idempotency_guard" - ) + text("alter table authority_idempotency_records disable trigger authority_idempotency_guard") ) await connection.execute( text( @@ -9155,14 +9151,11 @@ async def authorization_factory(authorization_database_env: str): ) ) await connection.execute(text("delete from authority_idempotency_records")) + await connection.execute(text("set constraints all immediate")) await connection.execute( - text( - "alter table authority_idempotency_records enable trigger authority_idempotency_guard" - ) - ) - await connection.execute( - text("alter table audit_events enable trigger audit_events_reject_update_delete") + text("alter table authority_idempotency_records enable trigger authority_idempotency_guard") ) + await connection.execute(text("alter table audit_events enable trigger audit_events_reject_update_delete")) await engine.dispose() diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 7b39d2611..facb2ad10 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -2168,3 +2168,23 @@ def test_checker_output_partition_additions_are_exact(): _partition(sorted([retained, *targets, "backend/app/modules/artifacts/arbitrary_output.py"])), trusted) with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): ownership._validate_additive_partition_transition(_partition(sorted(targets)), trusted) + + +def test_post_submit_authority_partition_replacement_is_exact() -> None: + """Retire the unavailable participant and register only the three AUTH owners.""" + retained = "backend/app/core/config.py" + old = "backend/app/modules/checkers/execution_authority.py" + additions = { + "backend/app/modules/authorization/domain/post_submit.py", + "backend/app/modules/authorization/post_submit_authorization.py", + "backend/app/modules/authorization/prepared_post_submit_replay.py", + } + trusted = _partition(sorted({retained, old})) + current = _partition(sorted({retained, *additions})) + ownership._validate_additive_partition_transition(current, trusted) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *additions, "backend/app/modules/authorization/extra.py"})), trusted, + ) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition(_partition(sorted(additions)), trusted) diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 69d72598e..61affafa8 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -259,6 +259,13 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: shared_b = modules_by_lane[catalogue.PARTITIONED_SHARED_LANES[1]] assert shared_a == shared_b == set(catalogue.SHARED_FOUNDATION_MODULES) assert { + "tests/authorization/post_submit/test_atomicity.py", + "tests/authorization/post_submit/test_concurrency.py", + "tests/authorization/post_submit/test_live_authority.py", + "tests/authorization/post_submit/test_migration.py", + "tests/authorization/post_submit/test_receipt_custody.py", + "tests/authorization/post_submit/test_principals.py", + "tests/authorization/post_submit/test_timeout.py", "tests/authorization/post_policy/test_concurrency.py", "tests/authorization/post_policy/test_context.py", "tests/authorization/post_policy/test_prepared.py", diff --git a/backend/tests/test_coverage_contract.py b/backend/tests/test_coverage_contract.py index f23b29be0..b585f18a7 100644 --- a/backend/tests/test_coverage_contract.py +++ b/backend/tests/test_coverage_contract.py @@ -12,7 +12,7 @@ sys.path.insert(0, str(SCRIPTS)) import coverage_policy as policy # noqa: E402 -HEAD = "0009_checker_material_lineage" +HEAD = "0010_post_submit_authority" SHA = "a" * 40 PEP695_INVALID = sys.version_info < (3, 12) diff --git a/backend/tests/test_post_submit_materialization.py b/backend/tests/test_post_submit_materialization.py index 297c052e0..8f8dbe86d 100644 --- a/backend/tests/test_post_submit_materialization.py +++ b/backend/tests/test_post_submit_materialization.py @@ -7,7 +7,6 @@ import pytest from sqlalchemy import text -from app.adapters.artifacts import post_submission_materialization from app.core.identifiers import new_record_id from app.modules.checkers.api.materialization import ( PostSubmissionMaterializationUnavailable, PostSubmissionMaterializationFailure, @@ -15,6 +14,8 @@ from tests.checkers.post_submit.support import change_request from tests.checkers.post_submit.test_result_contract import 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 class Consumer: @@ -58,8 +59,9 @@ async def test_exact_materialization_reads_verified_original_and_revokes_view(tm from tests.test_s3_artifact_store import provision_minio_bucket await provision_minio_bucket.__wrapped__() async with material_fixture(tmp_path, isolated_database_env, provider=provider) as h: + execution = await material_execution(h) consumer = Consumer(h.files) - material = await h.service.materialize(h.request, consumer) + material = await h.service.materialize(execution, consumer) assert material.submission_id == h.created.submission_id assert material.submission_version == h.created.submission_version assert material.admission_id == h.created.admission_id @@ -69,7 +71,6 @@ async def test_exact_materialization_reads_verified_original_and_revokes_view(tm assert material.byte_count == len(h.data) assert material.semantic_manifest_sha256 == h.manifest.sha256 assert consumer.calls == len(h.store.opens) == 1 - assert len(h.authority.selections) == 1 material.evaluation.validate_request(h.request) assert_closed(h, consumer) async with h.factory() as session: @@ -84,41 +85,39 @@ async def test_exact_materialization_reads_verified_original_and_revokes_view(tm "semantic_manifest_sha256", "evaluation"} -async def test_default_composition_denies_before_database_provider_or_scratch(): - from tests.checkers.post_submit.support import request - class Forbidden: - def __getattr__(self, name): - pytest.fail(f"protected access: {name}") - def __call__(self, *args, **kwargs): - pytest.fail("database access") - forbidden = Forbidden() - service = post_submission_materialization(sessions=forbidden, store=forbidden, namespace=forbidden, - preparation=forbidden, inspector=forbidden) - with pytest.raises(PostSubmissionMaterializationUnavailable, match="materialization_unavailable"): - await service.materialize(request(), forbidden) +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) + await service_link_state(h.factory, ServiceIdentity.ARTIFACT_MATERIALIZER, active=False) + consumer = Consumer(h.files) + with pytest.raises(PostSubmissionMaterializationUnavailable, match="authority_unavailable"): + await h.service.materialize(execution, consumer) + assert h.store.opens == [] and consumer.calls == 0 and not h.preparation._active async def test_foreign_or_changed_request_denies_before_provider(tmp_path, isolated_database_env): async with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) consumer = Consumer(h.files) for field in ("task_id", "assignment_id", "submission_id", "binding_id", "content_id", "submission_version", "byte_count", "content_sha256"): value = (2 if field == "submission_version" else h.request.byte_count + 1 if field == "byte_count" else "sha256:" + "0" * 64 if field == "content_sha256" else new_record_id()) with pytest.raises(PostSubmissionMaterializationUnavailable): - await h.service.materialize(change_request(h.request, **{field: value}), consumer) + await h.service.materialize(execution.model_copy(update={"request": change_request(h.request, **{field: value})}), consumer) assert h.store.opens == [] and consumer.calls == 0 assert not h.preparation._active # A valid control reaches the same consumer through the real stored selection. - await h.service.materialize(h.request, consumer) + await h.service.materialize(execution, consumer) assert consumer.calls == 1 @pytest.mark.parametrize("outcome", ["failure", "cancel", "replica_drift", "status_drift"]) async def test_async_exit_and_concurrent_drift_never_return_stale_material(tmp_path, isolated_database_env, outcome): async with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) consumer = Consumer(h.files) consumer.release = asyncio.Event() - operation = asyncio.create_task(h.service.materialize(h.request, consumer)) + operation = asyncio.create_task(h.service.materialize(execution, consumer)) await asyncio.wait_for(consumer.entered.wait(), 10) if outcome == "failure": consumer.failure = ValueError("controlled consumer failure") @@ -133,7 +132,7 @@ async def test_async_exit_and_concurrent_drift_never_return_stale_material(tmp_p await session.execute(text("set local lock_timeout='300ms'")) if outcome == "replica_drift": await session.execute(text("update artifact_replicas set availability_state='unavailable' where id=:id"), - {"id": str(h.authority.selections[0][1].replica_id)}) + {"id": str(h.replica_id)}) else: await session.execute(text("update submissions set status='checks_failed' where id=:id"), {"id": str(h.created.submission_id)}) @@ -147,15 +146,19 @@ async def test_async_exit_and_concurrent_drift_never_return_stale_material(tmp_p 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 with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) consumer = Consumer(h.files) original = h.store.open h.store.open = lambda _: _bytes(h.data[:-1] + bytes([h.data[-1] ^ 1])) with pytest.raises(PostSubmissionMaterializationFailure, match="material_unavailable"): - await h.service.materialize(h.request, consumer) + 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(change_request(h.request, structural_input=packet), consumer) + await h.service.materialize(execution, consumer) assert consumer.calls == 0 and not h.preparation._active @@ -163,6 +166,7 @@ async def test_abort_during_projection_prevents_consumer_entry(tmp_path, isolate from contextlib import contextmanager from threading import Event async with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) entered, release = Event(), Event() original = h.inspector._projected_tree @contextmanager @@ -173,7 +177,7 @@ def paused_projection(*args, **kwargs): yield tree monkeypatch.setattr(h.inspector, "_projected_tree", paused_projection) consumer = Consumer(h.files) - operation = asyncio.create_task(h.service.materialize(h.request, consumer)) + operation = asyncio.create_task(h.service.materialize(execution, consumer)) assert await asyncio.to_thread(entered.wait, 10) operation.cancel() await asyncio.sleep(0.05) @@ -188,10 +192,11 @@ def paused_projection(*args, **kwargs): async def test_consumer_deadline_revokes_material_and_releases_scratch(tmp_path, isolated_database_env): from tests.test_checker_materialization import _limits async with material_fixture(tmp_path, isolated_database_env, scratch_limits=_limits(total_deadline_seconds=2)) as h: + execution = await material_execution(h) consumer = Consumer(h.files) consumer.release = asyncio.Event() with pytest.raises(PostSubmissionMaterializationFailure, match="material_unavailable"): - await asyncio.wait_for(h.service.materialize(h.request, consumer), 10) + await asyncio.wait_for(h.service.materialize(execution, consumer), 10) assert consumer.calls == 1 assert_closed(h, consumer) @@ -202,9 +207,10 @@ async def test_post_submit_expansion_cannot_bypass_aggregate_quota(tmp_path, iso async with material_fixture(tmp_path, isolated_database_env, scratch_limits=_limits( aggregate_reserved_bytes=HARD_MAXIMUM_ARTIFACT_BYTES, )) as h: + execution = await material_execution(h) consumer = Consumer(h.files) with pytest.raises(PostSubmissionMaterializationFailure, match="material_unavailable"): - await h.service.materialize(h.request, consumer) + await h.service.materialize(execution, consumer) assert consumer.calls == 0 assert not h.preparation._active assert list((h.scratch / "workspaces").iterdir()) == [] @@ -212,6 +218,7 @@ async def test_post_submit_expansion_cannot_bypass_aggregate_quota(tmp_path, iso async def test_stored_manifest_mismatch_never_reaches_consumer(tmp_path, isolated_database_env): async with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) consumer = Consumer(h.files) # Deliberately corrupt retained custody in this isolated database. Normal # database guards forbid these writes; exercise ART's independent byte check. @@ -228,7 +235,7 @@ async def test_stored_manifest_mismatch_never_reaches_consumer(tmp_path, isolate for table in ("submission_bundle_admissions", "pre_submit_evidence_sets"): await session.execute(text(f"alter table {table} enable trigger user")) with pytest.raises(PostSubmissionMaterializationFailure, match="manifest_mismatch"): - await h.service.materialize(h.request, consumer) + await h.service.materialize(execution, consumer) assert len(h.store.opens) == 1 and consumer.calls == 0 assert not h.preparation._active assert list((h.scratch / "workspaces").iterdir()) == [] @@ -236,13 +243,14 @@ async def test_stored_manifest_mismatch_never_reaches_consumer(tmp_path, isolate async def test_foreign_consumer_result_is_rejected_and_cleaned(tmp_path, isolated_database_env): async with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) class WrongResult(Consumer): async def evaluate(self, request, view): await super().evaluate(request, view) return result(request, evaluation_generation=request.evaluation_generation + 1) consumer = WrongResult(h.files) with pytest.raises(ValueError, match="request mismatch"): - await h.service.materialize(h.request, consumer) + await h.service.materialize(execution, consumer) assert consumer.calls == 1 assert_closed(h, consumer) @@ -250,6 +258,7 @@ async def evaluate(self, request, view): async def test_provider_stream_has_no_selection_transaction_and_rejects_drift(tmp_path, isolated_database_env): from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine async with material_fixture(tmp_path, isolated_database_env) as h: + execution = await material_execution(h) # Tag only selection connections so pg_stat_activity can observe the # actual materializer session, independently of fixture/observer traffic. tag = "material-selection-" + str(new_record_id()) @@ -266,7 +275,7 @@ async def paused_stream(reference): yield chunk h.store.open = paused_stream consumer = Consumer(h.files) - operation = asyncio.create_task(h.service.materialize(h.request, consumer)) + operation = asyncio.create_task(h.service.materialize(execution, consumer)) try: await asyncio.wait_for(entered.wait(), 10) assert consumer.calls == 0 @@ -284,7 +293,7 @@ async def paused_stream(reference): await session.execute(text("set local lock_timeout='300ms'")) await session.execute(text( "update artifact_replicas set availability_state='unavailable' where id=:id" - ), {"id": str(h.authority.selections[0][1].replica_id)}) + ), {"id": str(h.replica_id)}) release.set() with pytest.raises(PostSubmissionMaterializationUnavailable): await asyncio.wait_for(operation, 10) diff --git a/backend/tests/test_post_submit_selection.py b/backend/tests/test_post_submit_selection.py index eecfa4075..b4352eb8a 100644 --- a/backend/tests/test_post_submit_selection.py +++ b/backend/tests/test_post_submit_selection.py @@ -10,6 +10,8 @@ from app.modules.checkers.api.materialization import PostSubmissionMaterializationUnavailable from app.modules.tasks.api.submitted_bundle import SubmittedBundleRequest, SubmittedBundleUnavailable from tests.checkers.post_submit.support import change_request +from tests.checkers.execution.support import material_execution +from app.modules.artifacts.post_submit_selection import select_post_submission_material from tests.post_submit_materialization_helpers import material_fixture from tests.test_post_submit_materialization import Consumer @@ -62,9 +64,8 @@ def observe(connection, cursor, statement, parameters, context, executemany): } -async def test_frozen_context_substitution_and_authority_denial_precede_io(tmp_path, isolated_database_env): +async def test_frozen_context_substitution_rejected_by_art_selection(tmp_path, isolated_database_env): async with material_fixture(tmp_path, isolated_database_env) as h: - consumer = Consumer(h.files) 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}) @@ -72,19 +73,10 @@ async def test_frozen_context_substitution_and_authority_denial_precede_io(tmp_p "observed_context": h.request.structural_input.observed_context.model_copy(update={name: value}), }) with pytest.raises(PostSubmissionMaterializationUnavailable, match="identity_mismatch"): - await h.service.materialize(change_request(h.request, expected_context=expected, structural_input=packet), consumer) - assert not h.store.opens and consumer.calls == 0 - # Explicit hidden seam denial, not a claim of live AUTH replay storage. - class RejectSelection: - async def preflight(self, request): - pass - async def authorize(self, request, selection): - assert selection.submission.admission_id == h.created.admission_id - raise PostSubmissionMaterializationUnavailable("controlled_generation_denied") - h.service._authority = RejectSelection() - with pytest.raises(PostSubmissionMaterializationUnavailable, match="controlled_generation_denied"): - await h.service.materialize(h.request, consumer) + await select_material(h, change_request(h.request, expected_context=expected, structural_input=packet)) 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 async def test_valid_foreign_lineage_mixes_deny_before_material_access(tmp_path, isolated_database_env, monkeypatch): @@ -100,14 +92,11 @@ async def test_valid_foreign_lineage_mixes_deny_before_material_access(tmp_path, assert first.created.admission_id != second.created.admission_id # Each untouched stored lineage must pass through real materialization. for own in (first, second): - value = await own.service.materialize(own.request, Consumer(own.files)) + value = await own.service.materialize(await material_execution(own), Consumer(own.files)) assert value.submission_id == own.created.submission_id own.store.opens.clear() - own.authority.selections.clear() def forbidden(*args, **kwargs): pytest.fail("foreign lineage reached provider, scratch, or consumer") - class ForbiddenConsumer: - evaluate = staticmethod(forbidden) for own, foreign in ((first, second), (second, first)): with monkeypatch.context() as patch: patch.setattr(own.store, "open", forbidden) @@ -125,8 +114,17 @@ class ForbiddenConsumer: expected_context=foreign.request.expected_context, structural_input=foreign.request.structural_input) mixed = change_request(own.request, **mix) - with pytest.raises(PostSubmissionMaterializationUnavailable): - await own.service.materialize(mixed, ForbiddenConsumer()) - assert not own.authority.selections + with pytest.raises((PostSubmissionMaterializationUnavailable, SubmittedBundleUnavailable)): + await select_material(own, mixed) + assert not own.store.opens assert not own.preparation._active assert list((own.scratch / "workspaces").iterdir()) == [] + + +async def select_material(h, request): + """Exercise ART selection itself, independently of earlier CHECKERS lease guards.""" + async with h.factory() as session: + return await select_post_submission_material( + session, tasks=submitted_bundle_port(session), request=request, + namespace=h.namespace, store=h.store, + ) diff --git a/backend/tests/test_pre_submit_attempt_authority_integration.py b/backend/tests/test_pre_submit_attempt_authority_integration.py index 3667f60b7..06821dbd3 100644 --- a/backend/tests/test_pre_submit_attempt_authority_integration.py +++ b/backend/tests/test_pre_submit_attempt_authority_integration.py @@ -6,7 +6,7 @@ import pytest from app.adapters.artifacts import CheckerPhaseService -from tests.checkers.execution.support import denied_executor +from tests.checkers.execution.support import forbidden_post_submission from sqlalchemy import select from app.modules.artifacts.authorization import PreparedPreSubmitMaterializationAuthorization @@ -71,7 +71,7 @@ async def test_completed_replay_authorizes_retry_custody_and_stored_original_gen ) workflow._materialization._authorization = authority phases = CheckerPhaseService( - pre_submission=workflow, post_submission=denied_executor(), + pre_submission=workflow, post_submission=forbidden_post_submission(), ) try: reservation = await _reserve_with_real_materializer( diff --git a/backend/tests/test_pre_submit_attempt_lock_order.py b/backend/tests/test_pre_submit_attempt_lock_order.py index 660ab717c..d5d7dd228 100644 --- a/backend/tests/test_pre_submit_attempt_lock_order.py +++ b/backend/tests/test_pre_submit_attempt_lock_order.py @@ -12,7 +12,7 @@ import pytest from app.adapters.artifacts import CheckerPhaseService -from tests.checkers.execution.support import denied_executor +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 @@ -194,7 +194,7 @@ async def _configure_participants( evidence=evidence_owner, checker_service=CheckerPhaseService( pre_submission=evidence_owner, - post_submission=denied_executor(), + post_submission=forbidden_post_submission(), ), durable_put=object(), ) diff --git a/backend/tests/test_submission_bundle_preparation_recovery.py b/backend/tests/test_submission_bundle_preparation_recovery.py index c21fadd25..fd90e7e49 100644 --- a/backend/tests/test_submission_bundle_preparation_recovery.py +++ b/backend/tests/test_submission_bundle_preparation_recovery.py @@ -8,7 +8,7 @@ import pytest from app.adapters.artifacts import CheckerPhaseService -from tests.checkers.execution.support import denied_executor +from tests.checkers.execution.support import forbidden_post_submission from fastapi import HTTPException from starlette.requests import Request @@ -99,7 +99,7 @@ def _preparation_replay_runtime(prepare_bytes, evidence_id, *, eligible): runtime.checker_service = CheckerPhaseService( pre_submission=runtime.evidence, - post_submission=denied_executor(), + post_submission=forbidden_post_submission(), ) runtime.checker_service.evaluate_pre_submission = AsyncMock( wraps=runtime.checker_service.evaluate_pre_submission, diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 92b6f44cc..941de96c7 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -406,6 +406,7 @@ async def delete_audit_fixture_as_owner(session: AsyncSession, event_id: str) -> text("delete from audit_events where id = :event_id"), {"event_id": event_id}, ) + await session.execute(text("set constraints all immediate")) await session.execute( text("alter table audit_events enable trigger audit_events_reject_update_delete") ) diff --git a/docs/architecture_brief/README.md b/docs/architecture_brief/README.md index 16a589d8f..a676881d7 100644 --- a/docs/architecture_brief/README.md +++ b/docs/architecture_brief/README.md @@ -9,7 +9,8 @@ This folder contains the shareable Workstream architecture brief. The brief uses the C4-PlantUML diagrams from `docs/diagrams/` and packages them into a single PDF for team review. The source, PDF and images distinguish delivered canonical history reads from -unavailable post-submit execution, routing and recovery. Lifecycle diagrams are +exact-authorized hidden post-submit execution from unavailable automatic dispatch, +routing and public recovery. Lifecycle diagrams are target views; the [roadmap](../roadmap_status.md) owns capability status. ## Render diff --git a/docs/architecture_brief/images/backend_v01_components.png b/docs/architecture_brief/images/backend_v01_components.png index 7bdd63c32..0ac0579bc 100644 Binary files a/docs/architecture_brief/images/backend_v01_components.png and b/docs/architecture_brief/images/backend_v01_components.png differ diff --git a/docs/architecture_brief/images/task_lifecycle_sequence.png b/docs/architecture_brief/images/task_lifecycle_sequence.png index e5586138f..0db5e26e3 100644 Binary files a/docs/architecture_brief/images/task_lifecycle_sequence.png and b/docs/architecture_brief/images/task_lifecycle_sequence.png differ diff --git a/docs/architecture_brief/images/workstream_v01_container.png b/docs/architecture_brief/images/workstream_v01_container.png index ce0088284..2ca532518 100644 Binary files a/docs/architecture_brief/images/workstream_v01_container.png and b/docs/architecture_brief/images/workstream_v01_container.png differ diff --git a/docs/architecture_brief/task_lifecycle_sequence.puml b/docs/architecture_brief/task_lifecycle_sequence.puml index 0b2776f3c..c103b05ae 100644 --- a/docs/architecture_brief/task_lifecycle_sequence.puml +++ b/docs/architecture_brief/task_lifecycle_sequence.puml @@ -31,7 +31,8 @@ note over PM, Checks Target sequence. Canonical history reads are delivered. Public manager guide activation is delivered. Hidden durable checker execution/currentness is delivered. -Live authority and automatic routing remain unavailable. +Exact input/execute/finalize service authority is delivered. + Automatic dispatch and routing remain unavailable. Flow authenticates; canonical AUTH decides from live exact grants. end note diff --git a/docs/architecture_brief/workstream_architecture_brief.md b/docs/architecture_brief/workstream_architecture_brief.md index d44ea8a9f..f75c2df84 100644 --- a/docs/architecture_brief/workstream_architecture_brief.md +++ b/docs/architecture_brief/workstream_architecture_brief.md @@ -28,9 +28,10 @@ decision an attributable Review and reviewer contribution, and every accepted task an immutable FinalAcceptance before the submitter contribution. The diagrams depict target architecture, not a fully connected live lifecycle. -Canonical retained submission/checker history is delivered. Post-submit execution, -routing and recovery are unavailable; their removed execution and repair paths are -not alternatives. The lifecycle sequence depicts the planned human-review branch. The versioned `human_review_required` defaults true in the existing locked +Canonical retained submission/checker history and hidden durable post-submit +execution with unfinished-attempt recovery are delivered. Exact service authority +is implemented; automatic dispatch/routing and authorized terminal repair remain +unavailable. Removed execution and repair paths are not alternatives. The lifecycle sequence depicts the planned human-review branch. The versioned `human_review_required` defaults true in the existing locked ReviewPolicy; false uses an authorized automated acceptance source and shared CON participant without a reviewer contribution. That runtime remains pending, not enabled by checker success alone. The [product-builder handoff](../../.commitrail/changes/pre-review-plan-reconciliation.md#product-builder-handoff-implement-the-setting-next) @@ -127,7 +128,7 @@ checker/job boundary. | React + Vite operations UI | Planned internal operations dashboard for project, task, submission, review, and compensation fulfillment workflows. Reputation UI remains deferred. | | FastAPI backend | API contracts, workflow rules, auth dependency, lifecycle guards, module orchestration, and audit writes. | | Celery worker boundary | Durable project setup and registered background jobs; checker execution is a target boundary. FastAPI background tasks are not the Workstream product-job boundary. | -| CHECKERS | Delivered retained-history reads and hidden durable post-submit execution/recovery; live authority and automatic routing remain unavailable. | +| CHECKERS | Delivered retained-history reads and hidden durable post-submit execution/recovery; exact service authority is delivered; automatic dispatch and routing remain unavailable. | | Storage interface | Keeps file/evidence semantics stable while local storage and the hosted AWS S3 profile implement the same provider-neutral port. | | Postgres | Durable record database for the full Workstream lifecycle. | @@ -158,9 +159,9 @@ The backend component view zooms into the FastAPI container. It shows how the mo ## Lifecycle Sequence The sequence below shows the target v0.1 loop. Public manager guide activation is -delivered, as is hidden durable post-submit execution/recovery. Live authority -and automatic routing remain pending. Retained history does not execute -checkers or authorize acceptance. +delivered, as is hidden durable post-submit execution/recovery with exact +input, execute and finalize service authority. Automatic dispatch and routing +remain pending. Retained history does not execute checkers or authorize acceptance.
Workstream task lifecycle sequence diagram diff --git a/docs/architecture_brief/workstream_architecture_brief.pdf b/docs/architecture_brief/workstream_architecture_brief.pdf index 826735670..f995a4137 100644 Binary files a/docs/architecture_brief/workstream_architecture_brief.pdf and b/docs/architecture_brief/workstream_architecture_brief.pdf differ diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index ae995c8d6..7ccebe556 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -155,8 +155,7 @@ 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; production authority remains deny-only -until ARCH-04D2. No public execution route or dispatcher is activated. +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. @@ -177,7 +176,11 @@ recovery retains the attempt; terminal infrastructure failures never restart or publish a routable result. No generated outputs or provider inference are enabled. The current-result port checks the exact request inside the caller transaction. -Production composition denies execute/finalize and material access until ARCH-04D2. +ARCH-04D2 composes real fixed-service materialization, execution and finalization +authority. Each phase obtains fresh AUTH/PREP before feature locks. Materialization +checks the exact current execution lease and revalidates its original receipt after +I/O. Execute/finalize receipts are bound to the persisted phase facts by PostgreSQL; +terminal replay validates both original receipts without appending new evidence. No new Celery task, delivery handler or public execution endpoint exists. ARCH-04E must recheck currentness when consuming a completion event and apply the locked ReviewPolicy; CHECKERS never accepts a contribution or mutates TASK state. @@ -352,18 +355,18 @@ prepared authorization and opens no transaction before ART's existing executor obtains fresh authority. Replay returns ART's canonical result unchanged. The post command validates and delegates CHECKER's closed value contract. -Production composes the canonical executor with separate deny-only execute and -finalize authorities. ARCH-04C reserves the exact request, fences execution attempts and +Production composes the canonical executor with separate exact execute and +finalize authority through AUTH/PREP. ARCH-04C reserves the exact request, fences execution attempts and atomically persists complete closed member results and a completion event; it -does not activate material reads or automatic dispatch. ARCH-04B supplies the hidden ART input port: exact consumed +does not activate automatic dispatch. ARCH-04B supplies the hidden ART input port: exact consumed Submission bytes, rebuilt manifest, async scoped file access and cleanup, followed -by a fresh material-selection check. Production composition denies materialization. +by a fresh material-selection check. ARCH-04D2 supplies fresh fixed-service materialization authority before and after I/O. ARCH-04B2 now supplies hidden typed output storage, byte-free recovery and flush-only verified binding. The CHECKERS reservation reader now verifies the exact current request/run/lease and returns zero slots; ART authority remains deny-only. Controlled nonempty slots prove ART mechanics only. ARCH-04C supports -that exact empty output set and owns durable execution, followed by ARCH-04D2 authority and -ARCH-04E routing. A returned evaluation value is not a stored +that exact empty output set and owns durable execution. ARCH-04D2 supplies input, +execute and finalize authority; ARCH-04E routing remains pending. 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 @@ -550,15 +553,15 @@ severities. The following is the intended end-to-end lifecycle. Pre-submit intake and retained-history reads are implemented. Hidden durable post-submit execution is -implemented; live execution and result routing remain unavailable pending -ARCH-04D2/04E. Known missing or corrupt input after ART authorization records a +implemented with ARCH-04D2 exact service authority; automatic dispatch and result +routing remain unavailable pending ARCH-04E. 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 contributor-correctable remediation and gates public intake and enabling the false-policy acceptance path; the true `allow_review` route may ship before ARCH-04F. ARCH-04B hidden input and ARCH-04B2 hidden output custody are delivered -with deny-only/unavailable production composition. The flow below is not a claim +with output-file authority unavailable; exact input authority is implemented. The flow below is not a claim that those jobs or transitions are live. ```text @@ -596,8 +599,8 @@ ARCH-04C owns CHECKERS result/currentness and its completion event, not TASK mutations. ARCH-04E owns the current `allow_review` manifest and TASK transition; ARCH-04F owns contributor-readable non-allow remediation before public cutover. The direct CHECKERS-to-TASK mutation, fabricated system actor and alternate -Celery gate are removed. Hidden durable execution is implemented; live authority -and routing remain unavailable until ARCH-04D2/04E. +Celery gate are removed. Hidden durable execution and exact service authority are implemented; automatic +routing remains unavailable until ARCH-04E. `review_pending` marks readiness for the separately owned WS-REV lifecycle. WS-REV alone creates `ReviewPacketManifest`, review queues, reviewer leases, @@ -641,8 +644,8 @@ The checker run records: - completion timestamp ARCH-04B input, ARCH-04B2 output custody and ARCH-04C result custody are -delivered. Reviewers will receive readiness proof after ARCH-04D2 activation and -ARCH-04E routing integration. That proof must identify the same immutable binding +delivered, together with ARCH-04D2 exact service authority. Reviewers will receive +readiness proof after ARCH-04E routing integration. That proof must identify the same immutable binding and manifest that passed automated checks; caller-owned manifest fields are not authority. @@ -652,8 +655,10 @@ terminal run retaining material must match the exact consumed admission, Submission binding/content, verified replica and semantic manifest, including infrastructure failures after materialization. Migration 0009 preserves valid retained history and refuses unprovable rows without rewriting or deleting them. -Current replica health is separate from immutable evidence identity. Production -remains deny-only until ARCH-04D2 supplies exact live service authority. +Current replica health is separate from immutable evidence identity. ARCH-04D2 +supplies exact service authority; migration 0010 binds execute/finalize receipts to +the exact run, request, lease, result and retained material. It refuses unprovable +retained receipts without rewriting or deleting evidence. A separate `ReadinessCertificate` record may be added later if reviewer routing needs a dedicated signed handoff object. v0.1 does not require that extra record. diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index aef9d7a90..d79956ffa 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -326,7 +326,7 @@ possible without rewriting the historical verified ancestry. Its migration refuses retained checker attempts or bindings whose missing evaluation digest or verified ancestry cannot be proven; it does not invent or delete retained data. ARCH-04C implements hidden durable execution/results; -production access remains deny-only until ARCH-04D2. Migration 0009 validates +ARCH-04D2 supplies exact input, execute and finalize service authority. Migration 0009 validates all terminal non-null material custody against ART's immutable consumed admission, binding, content and verified-replica lineage; current replica health is separate. The superseded CheckerRun payment column is removed; retained nullable Submission payment columns require @@ -1662,9 +1662,11 @@ false. No human Review is invented for that branch. Migration 0008 refuses nonempty checker history before DDL. It does not invent requests or erase retained evidence. Such an environment requires an explicit -preservation design. Production execute/finalize and ART access remain deny-only -until ARCH-04D2 installs real action-specific authority; opaque controlled test -receipts are not AUTH audit evidence. +preservation design. Migration 0010 (ARCH-04D2) adds deferred receipt foreign keys +and exact phase-specific audit validation for execute/finalize. Receipts bind the +request, run, lease, result, material and original execution receipt. Unprovable +retained receipts cause an atomic upgrade refusal; queued work is preserved. +Production uses real action-specific AUTH/PREP with fresh live identity checks. ## CheckerResult diff --git a/docs/architecture_system_architecture.md b/docs/architecture_system_architecture.md index cb2c1757e..de6d80412 100644 --- a/docs/architecture_system_architecture.md +++ b/docs/architecture_system_architecture.md @@ -101,7 +101,7 @@ Async policy: - Long-running setup and checker work must not block request/response paths. - Project setup automation runs through Celery. Canonical durable post-submit checker execution has hidden request/lease/result custody through ARCH-04C. - Production authority remains deny-only; ARCH-04D2 activates exact services and + ARCH-04D2 supplies exact input/execute/finalize service authority; ARCH-04E connects delivery through the existing shared outbox delivery process. - FastAPI background tasks are not used for Workstream product lifecycle jobs. - A different durable queue can replace Celery later only with an ADR-level reason. diff --git a/docs/current_system_data_flow.html b/docs/current_system_data_flow.html index f16c648dd..ce0ea3b2d 100644 --- a/docs/current_system_data_flow.html +++ b/docs/current_system_data_flow.html @@ -412,7 +412,7 @@

Workstream guide, task and retained submission history

This is a static walkthrough of the product flow with the adopted authorization target: external Flow authentication, project guides, locked policy context, task queue, submission packets, evidence, - retained checker history and authorization evidence. Hidden durable post-submit execution/recovery is implemented; live authority and automatic routing remain unavailable. + retained checker history and authorization evidence. Hidden durable post-submit execution/recovery and exact service authority are implemented; automatic dispatch and routing remain unavailable.

API boundary @@ -559,7 +559,7 @@

Retained checker history is inspectable

10

Hidden execution; live routing remains unavailable

-

Stored routing recommendations are historical evidence. The removed execution and gate-repair paths do not run, route work or retry. ARCH-04C implements exact request/lease custody, durable results and completion events. Production execution still denies until ARCH-04D2 adds service authority; ARCH-04E connects automatic routing.

+

Stored routing recommendations are historical evidence. The removed execution and gate-repair paths do not run, route work or retry. ARCH-04C implements exact request/lease custody, durable results and completion events. ARCH-04D2 supplies exact input, execute and finalize service authority with durable receipts; automatic dispatch and routing remain ARCH-04E work.

review_pending needs_revision @@ -641,13 +641,13 @@

Hard Invariants

-
Delivered foundations: guide/task policy lineage, hidden verified intake and immutable submission creation, authorized retained history, hidden durable checker execution and current-result custody.
-
Unavailable integration: live post-submit authority and automatic routing.
+
Delivered foundations: guide/task policy lineage, hidden verified intake and immutable submission creation, authorized retained history, hidden durable checker execution, exact service authority and current-result custody.
+
Unavailable integration: automatic post-submit dispatch and routing.
Unavailable recovery: authorized terminal checker retry and gate repair; hidden unfinished-attempt recovery is implemented.
diff --git a/docs/diagrams/backend_v01_components.md b/docs/diagrams/backend_v01_components.md index dadd0dba8..8e9878302 100644 --- a/docs/diagrams/backend_v01_components.md +++ b/docs/diagrams/backend_v01_components.md @@ -3,7 +3,7 @@ This is the target component view for the FastAPI modular monolith, not an inventory of delivered runtime components. Canonical authorized submission and checker history reads and hidden durable post-submit execution/recovery are -implemented. Live authority and automatic routing remain unavailable; the obsolete +implemented with exact service authority. Automatic dispatch and routing remain unavailable; the obsolete checker service and Celery worker have been removed. See the [current capability ledger](../roadmap_status.md) for delivery status. diff --git a/docs/diagrams/backend_v01_components.puml b/docs/diagrams/backend_v01_components.puml index e66992a92..b9801a070 100644 --- a/docs/diagrams/backend_v01_components.puml +++ b/docs/diagrams/backend_v01_components.puml @@ -5,7 +5,8 @@ legend top left Target architecture, not current runtime inventory. Canonical history reads are delivered. Hidden durable checker execution/currentness is delivered. - Live authority and automatic routing remain unavailable. + Exact input/execute/finalize service authority is delivered. + Automatic dispatch and routing remain unavailable. endlegend left to right direction diff --git a/docs/diagrams/rendered/backend_v01_components.svg b/docs/diagrams/rendered/backend_v01_components.svg index 78650ac80..ee9861507 100644 --- a/docs/diagrams/rendered/backend_v01_components.svg +++ b/docs/diagrams/rendered/backend_v01_components.svg @@ -1 +1 @@ -Workstream v0.1 Backend Target Component ViewTarget architecture, not current runtime inventory.Canonical history reads are delivered.Hidden durable checker execution/currentness is delivered.Live authority and automatic routing remain unavailable.FastAPI Backend - Modular MonolithHTTP + Auth BoundaryWorkflow ServicesShared Domain RulesPersistence BoundaryExternal Ports + AdaptersAPI RouterHTTP onlyrequest -> serviceerror -> responseCurrent ActorDependencyVerified token ->canonical actorPermissionPoliciesLive scoped grantsand exact resourcesPydanticSchemasRequest/responsecontractsProject GuideServiceProjects, guides,checker/review/revisionpoliciesTask QueueServiceTask contract,screening, release,claim, assignment,state guardsSubmissionServicePackets, evidence,artifact manifest,versions, lockingPlanned CheckerEvaluationChecker runs,blocking results,pre-review gateReview + RevisionServiceaccept,needs_revision,reject, findings,revision replayContribution /CompensationServiceReviewer/submitter records,award fulfillmentLifecycleGuardsAllowed task andsubmission transitionsAuditServiceAppend-onlyworkflow eventsRepositoriesProject, task,submission, checker,review, ledger,audit persistenceAuth VerifierInterfaceFlow AuthAdapterStorage PortLocal StorageAdapterPlanned Checker RunnerAdapterReact UI /API ClientExternalFlow AuthPostgresRecord databaseFile StoreEvidence andartifact objectsHTTP/JSONroute to domain servicesverify actorstate, audit, policy rulesread/write recordsstorage and planned checker executionverify tokenstore/read artifactsSQLAlchemy async \ No newline at end of file +Workstream v0.1 Backend Target Component ViewTarget architecture, not current runtime inventory.Canonical history reads are delivered.Hidden durable checker execution/currentness is delivered.Exact input/execute/finalize service authority is delivered.Automatic dispatch and routing remain unavailable.FastAPI Backend - Modular MonolithHTTP + Auth BoundaryWorkflow ServicesShared Domain RulesPersistence BoundaryExternal Ports + AdaptersAPI RouterHTTP onlyrequest -> serviceerror -> responseCurrent ActorDependencyVerified token ->canonical actorPermissionPoliciesLive scoped grantsand exact resourcesPydanticSchemasRequest/responsecontractsProject GuideServiceProjects, guides,checker/review/revisionpoliciesTask QueueServiceTask contract,screening, release,claim, assignment,state guardsSubmissionServicePackets, evidence,artifact manifest,versions, lockingPlanned CheckerEvaluationChecker runs,blocking results,pre-review gateReview + RevisionServiceaccept,needs_revision,reject, findings,revision replayContribution /CompensationServiceReviewer/submitter records,award fulfillmentLifecycleGuardsAllowed task andsubmission transitionsAuditServiceAppend-onlyworkflow eventsRepositoriesProject, task,submission, checker,review, ledger,audit persistenceAuth VerifierInterfaceFlow AuthAdapterStorage PortLocal StorageAdapterPlanned Checker RunnerAdapterReact UI /API ClientExternalFlow AuthPostgresRecord databaseFile StoreEvidence andartifact objectsHTTP/JSONroute to domain servicesverify actorstate, audit, policy rulesread/write recordsstorage and planned checker executionverify tokenstore/read artifactsSQLAlchemy async \ No newline at end of file diff --git a/docs/diagrams/rendered/workstream_v01_container.svg b/docs/diagrams/rendered/workstream_v01_container.svg index 7c6d8a5f2..da8d518f6 100644 --- a/docs/diagrams/rendered/workstream_v01_container.svg +++ b/docs/diagrams/rendered/workstream_v01_container.svg @@ -1 +1 @@ -Workstream v0.1 Target Container ViewPeopleWorkstream v0.1Durable RecordsProject ManagerFinance AuthorityHuman-AgentContributorReviewerOperatorAccess AdministratorReact + Vite Operations UIPlanned internaloperations dashboardFastAPI BackendModular monolith APIand workflow rulesAuthorization ServiceActor profiles, local grants,and capability checksCelery Worker BoundaryDurable project setup,checker, and product jobsChecker RunnerAutomated task andsubmission checksArtifact Storage ServiceAdmission, verification,binding, and provider portPostgresProjects, guides,tasks, submissions,checks, reviews,revisions, contribution,compensation, audit eventsPrivate Object StoreBehind ArtifactStoreLocal / MinIO / AWS S3Flow AuthToken issuer andhuman identity sourceusespublishes contribution policyusesusesusesadministers accessHTTP/JSONverify identity tokenauthorize capabilityprofiles and grantsSQLAlchemy asynctyped artifact operationsschedule workrun checksresultsmaterialize exact artifactsstore/readCanonical retained-history reads are delivered.Hidden durable checker execution/currentness is delivered.Live authority and automatic routing remain unavailable.Checker/job relationships below describe target integration. \ No newline at end of file +Workstream v0.1 Target Container ViewPeopleWorkstream v0.1Durable RecordsProject ManagerFinance AuthorityHuman-AgentContributorReviewerOperatorAccess AdministratorReact + Vite Operations UIPlanned internaloperations dashboardFastAPI BackendModular monolith APIand workflow rulesAuthorization ServiceActor profiles, local grants,and capability checksCelery Worker BoundaryDurable project setup,checker, and product jobsChecker RunnerAutomated task andsubmission checksArtifact Storage ServiceAdmission, verification,binding, and provider portPostgresProjects, guides,tasks, submissions,checks, reviews,revisions, contribution,compensation, audit eventsPrivate Object StoreBehind ArtifactStoreLocal / MinIO / AWS S3Flow AuthToken issuer andhuman identity sourceusespublishes contribution policyusesusesusesadministers accessHTTP/JSONverify identity tokenauthorize capabilityprofiles and grantsSQLAlchemy asynctyped artifact operationsschedule workrun checksresultsmaterialize exact artifactsstore/readCanonical retained-history reads are delivered.Hidden durable checker execution/currentness is delivered.Exact input/execute/finalize service authority is delivered.Automatic dispatch and routing remain unavailable.Checker/job relationships below describe target integration. \ No newline at end of file diff --git a/docs/diagrams/workstream_v01_container.puml b/docs/diagrams/workstream_v01_container.puml index b288beae7..e67427cea 100644 --- a/docs/diagrams/workstream_v01_container.puml +++ b/docs/diagrams/workstream_v01_container.puml @@ -74,7 +74,8 @@ storage_adapter --> file_store : store/read legend bottom Canonical retained-history reads are delivered. Hidden durable checker execution/currentness is delivered. - Live authority and automatic routing remain unavailable. + Exact input/execute/finalize service authority is delivered. + Automatic dispatch and routing remain unavailable. Checker/job relationships below describe target integration. endlegend diff --git a/docs/engineering/authorization_activation_custody.md b/docs/engineering/authorization_activation_custody.md index fd4303618..626315a33 100644 --- a/docs/engineering/authorization_activation_custody.md +++ b/docs/engineering/authorization_activation_custody.md @@ -57,14 +57,14 @@ mappings, and availability must remain identical. The table retains historical planning-custody labels, not a literal mapping of every typed runtime `ActionOwner`. XINT-06B groups runtime `WS-AUTH-001-ART-06A` post-submit materialization and -`WS-AUTH-001-ART-06B` output write/binding. ARCH-04D2 is their current replacement -activation boundary. ARCH-04B hidden input and ARCH-04B2 hidden output custody -and ARCH-04C hidden durable execution/results are delivered; ARCH-04D1 canonical terminal material custody is delivered; ARCH-04D2 live authority is next. ARCH-04B2 owns +`WS-AUTH-001-ART-06B` output write/binding. ARCH-04D2 replaces that grouping for exact input/execute/finalize authority; +output write/bind remains unavailable for the current zero-output catalogue. ARCH-04B hidden input and ARCH-04B2 hidden output custody +and ARCH-04C hidden durable execution/results are delivered; ARCH-04D1 canonical terminal material custody is delivered; ARCH-04D2 exact service authority is delivered; ARCH-04E1A routing-source facts are next. ARCH-04B2 owns fresh authority participants internally for each store, recovery and binding phase; public requests carry selectors and byte sources, never PREP handles. -The CHECKERS zero-slot reservation reader is implemented. Output authority remains deny-only, and -production materialization remains deny-only. The typed catalogue is unchanged -by this reconciliation. Do not implement an additional XINT-06B lane. +The CHECKERS zero-slot reservation reader is implemented. Output authority remains deny-only. Production materialization requires real +fixed-service AUTH/PREP and an exact current execution lease. Execute/finalize +use the fixed `workstream.checker.post_submit` identity and phase-specific receipts. Do not implement an additional XINT-06B lane. Runtime owner `WS-XINT-002-07` retains catalogue custody. The only approved v0.1 availability transition is 07A packet materialization. Evidence binding diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index 0bb2a80c6..8f6ecbbba 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -261,11 +261,11 @@ AUTH-OUTBOX-02 supplies shared delivery, and ARCH-03C1 supplies exact reconciler authority and decision-bound receipts. ARCH-03C2 delivers atomic producer publication and first handler registration with enforced prefork topology. Public manager activation and ARCH-03D hidden approved-guide intake are delivered. -Hidden exact post-submit input (ARCH-04B) is delivered with deny-only production +Hidden exact post-submit input (ARCH-04B) is delivered with exact ARCH-04D2 service authority. Hidden ARCH-04B2 output storage, recovery and verified binding are delivered. ARCH-04C adds hidden durable execution and exact zero-slot reservations. -Production authority remains deny-only pending ARCH-04D2; automatic delivery and -routing follow in ARCH-04E. Public intake remains deferred +ARCH-04D2 supplies exact execution/finalization authority and durable receipts. +Output-file authority remains unavailable; automatic delivery and routing follow in ARCH-04E. Public intake remains deferred to ARCH-02I after evaluation and remediation prerequisites. The intended unified flow uses one compilation result for sufficiency and @@ -412,8 +412,8 @@ route is removed. Submission recovery retains its separate internal evidence rea ### Submission Quality Gate — Target Contract Hidden durable post-submit execution and unfinished-attempt recovery are -implemented. Live authority, automatic routing and authorized terminal retry -remain unavailable. +implemented with exact input, execute and finalize service authority. Automatic +dispatch/routing and authorized terminal retry remain unavailable. `operations.checker.retry` is planned; the removed alternate Celery worker and repair route do not provide it. The required future behavior is: @@ -488,8 +488,8 @@ Before locking a submission packet: flow uses one internal pre-submit phase command with ART-owned evidence. Broader public Submission caller migration remains WS-ARCH-001-02I - no submission row is created until blocking pre-submit checks pass -- ARCH-04C implements hidden durable post-submit execution; live authority and - automatic routing remain unavailable until ARCH-04D2/04E +- ARCH-04C implements hidden durable post-submit execution with exact ARCH-04D2 + service authority; automatic dispatch and routing remain unavailable until ARCH-04E - the obsolete `/submissions/{submission_id}/finalize` repair and manual `POST /submissions/{submission_id}/checker-runs` routes are removed. Planned exact-authority recovery must not use them. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index ed21d5264..bf078863a 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -105,8 +105,8 @@ guide using canonical owner ports. ARCH-04B adds hidden verified Submission inpu with scoped async access. ARCH-04B2 adds hidden typed checker-output storage, byte-free recovery and flush-only verified binding over generic ART put/verification. ARCH-04C supplies hidden durable evaluation, immutable ordered -results, database-timed execution leases and atomic completion events. Production -authority remains deny-only pending ARCH-04D2. ARCH-04D1 enforces canonical +results, database-timed execution leases and atomic completion events. ARCH-04D2 supplies exact fixed-service input, execute and finalize authority +and database-bound receipts; output-file authority remains unavailable. ARCH-04D1 enforces canonical ART material lineage for all terminal results retaining material; public intake remains deferred to the later cutover prerequisites. Required success then branches on the locked ReviewPolicy: true routes to human `allow_review`; @@ -162,7 +162,7 @@ cannot be reused as post-submission review-gate evidence. See the | Contributor artifact preparation | **Hidden and proven** | One outer ZIP; bounded scratch inspection; canonical manifest; 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 | **Hidden with approved-guide lineage** | Separate versioned pre-submission catalogue, locked effective-plan compilation, platform/project checks during continuous preparation, blocking feedback before Submission creation, and one internal phase command covering execution/replay with the JSON precheck removed; ARCH-03D connects approved-guide lineage through the final durable handoff | Complete the canonical public cutover after evaluation/remediation prerequisites; passing intake must never substitute for post-submit evaluation | | 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 and locked policy lineage; 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 `allow_review` | **Planned; immediate integration milestone** | One canonical CHECKER post-submit catalogue/compiler used by existing consumers, internal phase service with deny-only production post-submit authority, hidden value contracts and structural-handler conformance; ARCH-04B hidden exact verified Submission materialization; ARCH-04B2 hidden typed output store/recovery and verified binding; ARCH-04C hidden durable execution, exact current-result custody, zero-output support and atomic completion events; ARCH-04D1 canonical ART material custody | ARCH-04D2 activates exact fixed services; ARCH-04E dispatches evaluation and publishes the canonical `allow_review` manifest | +| Post-submission evaluation and `allow_review` | **Planned; immediate integration milestone** | One canonical CHECKER post-submit catalogue/compiler used by existing consumers, internal phase service with exact fixed-service post-submit authority, hidden value contracts and structural-handler conformance; ARCH-04B hidden exact verified Submission materialization; ARCH-04B2 hidden typed output store/recovery and verified binding; ARCH-04C hidden durable execution, exact current-result custody, zero-output support and atomic completion events; ARCH-04D1 canonical ART material custody; ARCH-04D2 exact materialization/execute/finalize authority and durable receipt custody | ARCH-04E1A supplies routing-source facts; ARCH-04E dispatches evaluation and publishes the canonical `allow_review` manifest | | Review queue and lease | **Hidden persistence foundation** | Queue/admission idempotency and ReviewLease/preference persistence; complete unavailable REV action/principal catalogue and typed AUTH contracts | Packet-membership contract and manifest; Review schema; canonical admission from `allow_review`; claim/lease/packet authority; lease copies the Submission-stamped policy version with no CON lookup | | Review decision and revision | **Planned** | Review/revision policy identities and mutation authority; approved same-task revision-rebase semantics | Immutable findings and decisions; `accept`, `needs_revision`, and `reject`; complete-context revision preparation; finding responses; replacement contributor rules; replay and recovery | | Contribution and compensation truth | **Schema foundations plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings; public Finance policy administration | Persist ContributionRecord/CompensationAward and one shared FinalAcceptance/submitter operation for human accept or authorized false/pass routing. Only actual Reviews create reviewer records. Evaluate frozen actor rules into zero, one or two awards | @@ -428,7 +428,7 @@ their evidence is linked under [completed work](#what-has-been-completed). The [dependency and ownership plan](../.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md#current-dependency-contract) owns implementation sequencing. The existing checker phase service supports hidden pre-submit execution/replay and hidden durable post-submit execution. -Production post-submit authority and automatic routing remain unavailable. Live setup does not wait for downstream task/checker execution. +Exact post-submit service authority is implemented; automatic dispatch and routing remain unavailable. Live setup does not wait for downstream task/checker execution. The next dependency-safe product sequence is: @@ -455,7 +455,8 @@ The next dependency-safe product sequence is: empty output set and persists one current result with its completion event. ARCH-04D1 enforces canonical ART material lineage for every terminal result retaining material, with database rejection, rollback and safe-upgrade proof. - ARCH-04D2 must activate exact fixed services. ARCH-04E then dispatches evaluation + ARCH-04D2 supplies exact input/execute/finalize service authority and durable + receipt custody. Next, ARCH-04E1A supplies routing-source facts; ARCH-04E then dispatches evaluation and publishes an exact human `allow_review` manifest on true when no blocking failure exists. CHECKERS owns durable execution/currentness; the shared facade does not @@ -533,19 +534,19 @@ contributor and Project Manager projections. The obsolete token-role dependency, compatibility identity writer, manual checker execution, finalize repair and fabricated-actor Celery gate are removed. Retained records are preserved; ARCH-04C supplies hidden durable execution and unfinished-attempt recovery; -live authority and automatic routing remain pending ARCH-04D2/04E. +ARCH-04D2 supplies exact service authority; automatic routing remains ARCH-04E work. 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. Production access remains -deny-only; it does not activate durable checker execution or public intake. +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. 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; live output and -execution authority remain deny-only. Public intake is not activated. +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. ## Critical Dependency Map @@ -556,7 +557,7 @@ Delivered foundations (not a claim of full public integration) public manager activation context + exact guide activation/binding task/assignment/Submission lineage + hidden intake/creation ARCH-03D exact approved historical guide through durable intake handoff - ARCH-04B hidden verified Submission input (production authority deny-only) + ARCH-04B hidden verified Submission input ARCH-04B2 hidden checker output store/recovery/binding (authority deny-only) ARCH-04C hidden durable execution/current results + atomic completion event shared dispatcher + exact-authorized hidden assignment invalidation @@ -567,10 +568,11 @@ Delivered foundations (not a claim of full public integration) ARCH-03C7 bounded exact-authorized Audit Authority task history canonical contributor/manager Submission and checker history; obsolete gate removed ARCH-04D1 canonical ART material custody at terminal CHECKERS commit + ARCH-04D2 exact input/execute/finalize authority + durable receipts | v Remaining integration - live evaluation authority (ARCH-04D2) + automatic routing + remediation + routing-source facts (ARCH-04E1A) -> automatic dispatch/routing + remediation -> public intake and immutable admitted Submission cutover -> automatically consume the durable current result + required checks pass | @@ -683,7 +685,8 @@ remaining trace sequence is: [ARCH-04B input materialization](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04B.md) and [ARCH-04B2 output custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04B2.md) and [ARCH-04C durable execution](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04C.md) - lead to `04D2 -> 04E` for live authority and routing. The mandatory + and [ARCH-04D2 exact service authority](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04D2.md) + lead to `04E1A -> 04E1B -> 04E2 -> 04E3` for dispatch and routing. 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 diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index e1f3dfcad..788d32598 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -410,7 +410,8 @@ admission and admitted verified replica through its own records plus the TASK-ow `SubmittedBundlePort`. It verifies exact bytes and the canonical manifest before an async consumer receives a scoped read-only file view. The typed result carries material custody plus the existing closed evaluation value; it is not a durable -checker result. Production composition denies before protected reads until ARCH-04D2. +checker result. ARCH-04D2 requires exact live service authority and the current CHECKERS execution +lease before protected reads, then revalidates the same receipt after I/O. `CheckerOutputArtifactRequest` contains a CHECKERS-owned reservation selector and generated byte source. `store` obtains fresh authority for each phase, resolves the exact evaluation/run/checker-worker-lease/output-slot facts, applies the @@ -418,7 +419,7 @@ owner-declared media type and byte ceiling, and uses generic ART put and independent verification. `recover(selector)` uses the same current owner and authority facts to recover the stable operation without a byte source; it never puts or regenerates bytes. The current production composition supplies an -unavailable reservation port and deny-only authority, so neither method is a +zero-slot reservation port and deny-only output authority, so neither method is a live evaluator capability. The Submission binding is instead the terminal result of the typed `SubmissionAdmissionConsumptionPort`: it consumes one ready admission against the exact TASK-supplied Submission identity and locked lineage. The remaining @@ -1002,7 +1003,7 @@ ARCH-04B implements this hidden input path. It verifies complete original digest and size, then rebuilds the semantic manifest, reserves expansion against the existing aggregate scratch quota, and rechecks stored material selection after I/O. Database transactions and row locks do not span provider/consumer execution. -Production authority remains deny-only; ARCH-04D2 supplies live authority and +ARCH-04D2 supplies exact fixed-service input authority before and after I/O; ARCH-04C owns durable attempts/results and their final publication. Byte drift rejects materialization; this read does not fabricate an incident or mutate Submission lifecycle state. @@ -1499,7 +1500,7 @@ CHECKERS commit. Completed results require material; infrastructure failures wit material must also match. The comparison uses immutable admission ancestry, not the replica's current health. Upgrade excludes writers across preflight and installation, preserves valid retained evidence and refuses invalid lineage -without data repair or deletion. Exact live authority remains ARCH-04D2 work. +without data repair or deletion. ARCH-04D2 supplies exact materialization, execute and finalize authority. For a registered capability that produces logs or output bytes, those outputs must become verified artifact bindings through its reserved slots. The current structural catalogue produces no such artifacts. @@ -1516,9 +1517,9 @@ currentness and durable evaluation with an exact empty output set. The current structural catalogue declares zero output slots; controlled nonempty test reservations prove ART mechanics only. A future output-producing registered capability must compose bindings only for slots actually reserved by its owner. -Production materialization, output storage, execution and finalization authority -remain deny-only until ARCH-04D2; automatic TASK publication and routing remain -ARCH-04E work. +ARCH-04D2 supplies exact materialization, execution and finalization authority, +with database-bound execute/finalize receipts. Output write/bind authority remains +unavailable; automatic TASK publication and routing remain ARCH-04E work. Current ARCH-04C behavior records known post-authorization material failures as terminal, unroutable `material_unavailable`, after scratch cleanup and fresh @@ -1614,7 +1615,7 @@ cannot be proven; it neither invents those facts nor deletes retained data. The pre-submit checker materializer is a mandatory part of preparation, so its fixed-service AUTH activation must merge after hidden ART-04B1-04B3 and before contributor preparation is activated. ARCH-04B delivers hidden exact post-submit -input with deny-only production authority. ARCH-04B2 hidden checker-output +input with exact ARCH-04D2 fixed-service authority. ARCH-04B2 hidden checker-output custody is delivered; ARCH-04C now owns durable CHECKERS execution/results, and ARCH-04D2 owns their exact live activation. ARCH-04E separately owns TASK routing. Historical ART-06A/06B labels do not diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 392f35fd5..ef1126552 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -537,9 +537,9 @@ runtime `ActionOwner` values or the current implementation boundaries. In particular, the XINT-06B grouping corresponds to runtime `WS-AUTH-001-ART-06A` for post-submit materialization and `WS-AUTH-001-ART-06B` for checker-output write/binding. The current replacement -activation contract is ARCH-04D2. ARCH-04B hidden input and ARCH-04B2 output -custody and ARCH-04C hidden durable execution/results are delivered; -ARCH-04D2 activation is next. This does not reopen XINT-06B as a parallel implementation lane. Likewise ARCH-02G/02H +activation contract is ARCH-04D2. Exact post-submit input, execute and finalize +authority is delivered; checker-output write/bind remains planned because the +current catalogue produces no output files. ARCH-04E1A routing-source facts are next. This does not reopen XINT-06B as a parallel implementation lane. Likewise ARCH-02G/02H are replacement implementation boundaries, not automatic registry renames. Read exact runtime ownership from the typed catalogue. No planning-only change may promote or reassign an action. @@ -688,8 +688,8 @@ a planning document does not grant a service permission. |---|---|---|---| | `outbox.dispatch` | `workstream.outbox.dispatcher` | Event/claim generation/lease and exact phase; fresh authority for claim, invoke and finalize; no feature authority | AUTH-OUTBOX-02 authority/phase custody and Celery composition complete; feature handlers remain separate | | `task.assignment.authority_reconcile` | `workstream.task.assignment_reconciler` | Committed exact AUTH invalidation event, project/actor/grant-or-link, active pre-submit assignment; no wrong-role or submitted-history mutation | ARCH-03B9 hidden handler/fence and ARCH-03C1 real feature authority/decision receipts complete; ARCH-03C2 atomic producer wiring and registration | -| `checker.post_submit.execute` | `workstream.checker.post_submit` | Immutable Submission/request/generation, locked compiled policy, attempt and admitted service; exact pre-I/O authority | ARCH-04C hidden behavior, ARCH-04D2 activation | -| `checker.post_submit.finalize` | `workstream.checker.post_submit` | Exact execution request/fence, accepted result digest and required verified output bindings; fresh post-I/O authority and atomic evidence | ARCH-04C hidden behavior, ARCH-04D2 activation | +| `checker.post_submit.execute` | `workstream.checker.post_submit` | Immutable Submission/request/generation, locked compiled policy, attempt and admitted service; exact pre-I/O authority | Implemented by ARCH-04C/04D2; no dispatcher registration | +| `checker.post_submit.finalize` | `workstream.checker.post_submit` | Exact execution request/fence, accepted result digest, retained material and original execute receipt; fresh post-I/O authority and atomic evidence; current outputs are empty | Implemented by ARCH-04C/04D2; no dispatcher registration | | `task.post_submit.route` | `workstream.task.post_submit_router` | Committed completion event/claim, exact current CHECKER result/fence, immutable Submission, locked ReviewPolicy and TASK pre-review state; true permits admission, false/pass permits the shared acceptance consequence after its proof; never a human review decision or generic CON write | ARCH-04E1B hidden handler plus shared REV/CON proof for false, ARCH-04E2 activation, ARCH-04E3 live composition | Each action maps to the identically named permission in this table and only diff --git a/mcp_server/contracts/authorization_context_get.json b/mcp_server/contracts/authorization_context_get.json index c879937a2..89325cb3a 100644 --- a/mcp_server/contracts/authorization_context_get.json +++ b/mcp_server/contracts/authorization_context_get.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "10124f30bfbbb76a494277aff1d2c56e9813abfaf37e045cffda2eebe7df9949", + "canonical_sha256": "7e9bf06c60ffc388ba1f2bf945ab689c9442ad922803cc27df7f9e0146b3580a", "components": { "schemas": { "ActionId": { @@ -48,6 +48,8 @@ "actor.identity_link.revoke", "actor.identity_link.reactivate", "task.assignment.authority_reconcile", + "checker.post_submit.execute", + "checker.post_submit.finalize", "outbox.dispatch", "actor.service.provision", "project.contributor_candidate.list", @@ -531,7 +533,7 @@ "schema_version": 1, "source": { "action_id": "actor.authorization_context.read", - "backend_git_sha": "0645c5167a93e9bb6e4d4ff926bd13f305ee6b63", + "backend_git_sha": "8a5b344a8164e86e884139c5c92dc893f66239e5", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/actors/me/authorization-context"