diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index d77b97b5e..74066a553 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "unassigned_legacy_auth", - "content_sha256": "05b58f042b1241c8e1ab5e0db6da10165a249e93ffbccf7bea5a90f5ca195db8", - "end_line": 1536, + "content_sha256": "cdc05318700c08614ca1febffa50afd741ee32fbd8d1a24824a5fb6b54a13f3f", + "end_line": 1514, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1536, + "observed_lines": 1514, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -26,11 +26,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "46abd9b3cc7627a843b4a1c4e75698320972392ff10b45501b3d7a73babcdbc0", - "end_line": 1537, + "content_sha256": "8e6778a4d18de265663cec48c88f7f2eb380e30348ace88f5e057a0b4b3b8957", + "end_line": 1432, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1537, + "observed_lines": 1432, "path": "backend/app/modules/authorization/runtime.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -38,51 +38,39 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "c157192f98ba8db83115ceb19cc7352053f9a12b608e3bac45920c7a2d2d213d", + "content_sha256": "63e359f9917d22ff05e23a96b18d77b0588025381356b532ef3c951d2e6bb3ab", "end_line": 563, "hard_limit": 100, "kind": "production_function", - "observed_lines": 196, + "observed_lines": 194, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 368 + "start_line": 370 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "4f1b221fd8ff04984a74950f57089a6f3b0aec7e932c2b3fa8430bb073fa7729", - "end_line": 1061, + "content_sha256": "413f16e5199ed55cd4b22773c533447453c2196c7f68c90e769206bf7463261e", + "end_line": 1059, "hard_limit": 100, "kind": "production_function", - "observed_lines": 162, + "observed_lines": 158, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 900 + "start_line": 902 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "846a19c6f2a9337f774a751a949a4ea88909846c925be550d7fefaa393fd5f4d", - "end_line": 1536, + "content_sha256": "64a3376f7a2f6fd8d14fa21456a97e5498525fcd072a598fdfefbcb33fea8995", + "end_line": 889, "hard_limit": 100, "kind": "production_function", - "observed_lines": 108, - "path": "backend/app/modules/authorization/kernel.py", - "qualified_symbol": "AuthorizationService._stage_decision", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1429 - }, - { - "capability": "unassigned_legacy_auth", - "content_sha256": "2415370c64b92d484fcb32ae8ac8dbe7993c198d047abf26b391ff04c63c9745", - "end_line": 906, - "hard_limit": 100, - "kind": "production_function", - "observed_lines": 235, + "observed_lines": 213, "path": "backend/app/modules/authorization/prepared.py", "qualified_symbol": "PreparedAuthorizationService._binding", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 672 + "start_line": 677 }, { "capability": "unassigned_legacy_auth", @@ -158,23 +146,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "702e4d0d27eb49c19fed0ec7c420694fa842dc7967ad824621f0954b2063b6c1", - "end_line": 1753, + "content_sha256": "5ae46dbafad129c7c2527b2dda6deecae72e48d2e9f179d1e6fc17f31360053d", + "end_line": 3865, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1753, - "path": "backend/tests/test_audit.py", - "qualified_symbol": null, - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1 - }, - { - "capability": "unassigned_legacy_auth", - "content_sha256": "6f7d6f8a812cc289196a9468527159cf344650290f880bd5cfcb9e3a7f613ce9", - "end_line": 3877, - "hard_limit": 1200, - "kind": "test_file", - "observed_lines": 3877, + "observed_lines": 3865, "path": "backend/tests/test_auth.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -182,11 +158,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "93a8538b67f8b153e12ffa4357dd30191d8fc58ffc5402ef063e349c60a6d84f", - "end_line": 12751, + "content_sha256": "c18d66abda1529138d431c3187d98fa34e0c274d9db703ba0107eb54c6fc16ee", + "end_line": 12675, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 12751, + "observed_lines": 12675, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -324,305 +300,281 @@ "removal_chunk": "WS-AUTH-003-CLOSE", "start_line": 123 }, - { - "capability": "unassigned_legacy_auth", - "content_sha256": "982f7c01fbc085454707e4b4491b9e79289af6203b7c2718635732d61ba8c9f1", - "end_line": 702, - "hard_limit": 120, - "kind": "test_function", - "observed_lines": 189, - "path": "backend/tests/test_audit.py", - "qualified_symbol": "test_authority_input_rejects_unbounded_or_inconsistent_evidence", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 514 - }, - { - "capability": "unassigned_legacy_auth", - "content_sha256": "36820d7d6c9db7dac729710d8d87ab233d4882febd049e862961449550b52e8e", - "end_line": 1263, - "hard_limit": 120, - "kind": "test_function", - "observed_lines": 210, - "path": "backend/tests/test_audit.py", - "qualified_symbol": "test_database_rejects_malformed_and_mutated_audit_rows", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1054 - }, { "capability": "unassigned_legacy_auth", "content_sha256": "9df08af6d7d3193c9112ac885fbe242b0f6ef072dfe9536f91980a61d14a7b4c", - "end_line": 3857, + "end_line": 3845, "hard_limit": 120, "kind": "test_function", "observed_lines": 879, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2979 + "start_line": 2967 }, { "capability": "unassigned_legacy_auth", "content_sha256": "15c7147c11fa1802f43480b38a3678ac1a2586320ae2f0013622f1d63b2a3806", - "end_line": 2534, + "end_line": 2522, "hard_limit": 120, "kind": "test_function", "observed_lines": 535, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2000 + "start_line": 1988 }, { "capability": "unassigned_legacy_auth", "content_sha256": "188db6b76b3f6dbe4077dbbb724bc9fe7529058b099cc7409bc5a443686a096a", - "end_line": 2976, + "end_line": 2964, "hard_limit": 120, "kind": "test_function", "observed_lines": 440, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2537 + "start_line": 2525 }, { "capability": "unassigned_legacy_auth", "content_sha256": "eca9eeff229ee37c103188f9a31e278773a69e96f2fd354662723f840cf8f16a", - "end_line": 1997, + "end_line": 1985, "hard_limit": 120, "kind": "test_function", "observed_lines": 482, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1516 + "start_line": 1504 }, { "capability": "unassigned_legacy_auth", "content_sha256": "8ea087b906726103295a019967f8c38e42b55deb4e912fda3a41289fda23f35d", - "end_line": 1033, + "end_line": 1021, "hard_limit": 120, "kind": "test_function", "observed_lines": 647, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_controlled_service_actor_provisioning_includes_project_setup_and_is_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 387 + "start_line": 375 }, { "capability": "unassigned_legacy_auth", "content_sha256": "d26732dd125f8c2062b5a4b45b59fbccd283ee04939a9da7be1576235524292f", - "end_line": 1444, + "end_line": 1432, "hard_limit": 120, "kind": "test_function", "observed_lines": 409, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_service_actor_provisioning_failure_and_authority_races_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1036 + "start_line": 1024 }, { "capability": "unassigned_legacy_auth", "content_sha256": "951b44cc07e36002118fe93b7974e8d65851b0e2c3cec89a3031cbe42b014e2d", - "end_line": 7851, + "end_line": 7775, "hard_limit": 120, "kind": "test_function", "observed_lines": 140, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_actor_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7712 + "start_line": 7636 }, { "capability": "unassigned_legacy_auth", "content_sha256": "569084d6de89ff9eae71d526fc6c157aea7638f55ca93ad128b721fbda339be6", - "end_line": 8341, + "end_line": 8265, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_resource_digest_alone_rejects_substituted_role_and_disposition", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8220 + "start_line": 8144 }, { "capability": "unassigned_legacy_auth", "content_sha256": "90b8b670b4d277209617cc1aa794188b4fb3cd9d894b69d5dc3d0adfc885f6ed", - "end_line": 8569, + "end_line": 8493, "hard_limit": 120, "kind": "test_function", "observed_lines": 132, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_revoke_stages_complete_state_and_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8438 + "start_line": 8362 }, { "capability": "unassigned_legacy_auth", "content_sha256": "78d9bf3df08e5633e9a75720b1e4bf5b7d7b24019bc392b4cb7496a9ac5e5e8e", - "end_line": 10402, + "end_line": 10326, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_authorization_locks_refresh_cached_actor_lifecycle_state", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10281 + "start_line": 10205 }, { "capability": "unassigned_legacy_auth", "content_sha256": "df1df6ec2ba6aa55445e21cfcf4e4e3ad9664c9504313484a495b6a6df1e9047", - "end_line": 2998, + "end_line": 2996, "hard_limit": 120, "kind": "test_function", "observed_lines": 126, "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": 2873 + "start_line": 2871 }, { "capability": "unassigned_legacy_auth", "content_sha256": "05d1b020ecff0f9bc0a0567adc07f5b31a2f9dfb7828ae3ad34d4e1e7757797c", - "end_line": 8004, + "end_line": 7928, "hard_limit": 120, "kind": "test_function", "observed_lines": 151, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7854 + "start_line": 7778 }, { "capability": "unassigned_legacy_auth", "content_sha256": "c5d6d0d480ced964354915614f15202c0159bfbb281658da0d44186dffd17848", - "end_line": 6799, + "end_line": 6723, "hard_limit": 120, "kind": "test_function", "observed_lines": 142, "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": 6658 + "start_line": 6582 }, { "capability": "unassigned_legacy_auth", "content_sha256": "ebb76e63671195aa4d806ac602bfe58cb22bf82d00c8a70ab186785f3acd7f9b", - "end_line": 7140, + "end_line": 7064, "hard_limit": 120, "kind": "test_function", "observed_lines": 336, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6805 + "start_line": 6729 }, { "capability": "unassigned_legacy_auth", "content_sha256": "9ea4fc0ddbab4c7262a43bc3f498ee1afea3318e9a0b6c93c87763f9f22aae9c", - "end_line": 6630, + "end_line": 6554, "hard_limit": 120, "kind": "test_function", "observed_lines": 518, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_postgresql_failure_and_cancellation_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6113 + "start_line": 6037 }, { "capability": "unassigned_legacy_auth", "content_sha256": "46e0b031394da6fee856e48615732a17ff8d2276d9cedfb0ecaa3a6879410adb", - "end_line": 4094, + "end_line": 4092, "hard_limit": 120, "kind": "test_function", "observed_lines": 157, "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": 3938 + "start_line": 3936 }, { "capability": "unassigned_legacy_auth", "content_sha256": "345886721ddf7f56ae180ef2f5bc1ca85b75a11488a56a5f47ad80ac5fd5ee5f", - "end_line": 2185, + "end_line": 2184, "hard_limit": 120, "kind": "test_function", "observed_lines": 365, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_mutation_resources_and_prepared_scopes_are_closed", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1821 + "start_line": 1820 }, { "capability": "unassigned_legacy_auth", "content_sha256": "105667302ed6e8f2fd16ea7e95d642e72e41514673e1152503536e0520f9c362", - "end_line": 10277, + "end_line": 10201, "hard_limit": 120, "kind": "test_function", "observed_lines": 204, "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": 10074 + "start_line": 9998 }, { "capability": "unassigned_legacy_auth", "content_sha256": "756b7f99f9a743284934b4d85ce263617710d9b8119792ccb4526a1de04070a1", - "end_line": 11780, + "end_line": 11704, "hard_limit": 120, "kind": "test_function", "observed_lines": 233, "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": 11548 + "start_line": 11472 }, { "capability": "unassigned_legacy_auth", "content_sha256": "2e1d7db74ddb955b90a0ee12e4fb72b651a0f85d2746c76e09079c05b0b85252", - "end_line": 12751, + "end_line": 12675, "hard_limit": 120, "kind": "test_function", "observed_lines": 681, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_issue_postgresql_prep_binds_target_role_and_scope", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12071 + "start_line": 11995 }, { "capability": "unassigned_legacy_auth", "content_sha256": "cf7f0f09ac93a917c74fd66c0048a289f0a437ca89757f834c70afca273588b3", - "end_line": 1410, + "end_line": 1409, "hard_limit": 120, "kind": "test_function", "observed_lines": 214, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_mutation_routes_conceal_denials_and_preserve_self_guards_atomically", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1197 + "start_line": 1196 }, { "capability": "unassigned_legacy_auth", "content_sha256": "2f01493754052fa841ca40a5600f6fc68cfac966e719323a9a89e4b8c38a5744", - "end_line": 1673, + "end_line": 1672, "hard_limit": 120, "kind": "test_function", "observed_lines": 245, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_mutation_routes_enforce_project_lifecycle_without_disclosure", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1429 + "start_line": 1428 }, { "capability": "unassigned_legacy_auth", "content_sha256": "05621e885ed2f88d0ba1a072c1f263fc923939f7ce755a514e9872112aa830a1", - "end_line": 11296, + "end_line": 11220, "hard_limit": 120, "kind": "test_function", "observed_lines": 163, "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": 11134 + "start_line": 11058 }, { "capability": "unassigned_legacy_auth", @@ -696,29 +648,17 @@ "removal_chunk": "WS-AUTH-003-CLOSE", "start_line": 341 }, - { - "capability": "unassigned_legacy_auth", - "content_sha256": "efca18e30f3e8e9d7b16310a956b427262dc92bdf13165146b58523b25ea2687", - "end_line": 468, - "hard_limit": 100, - "kind": "test_helper", - "observed_lines": 228, - "path": "backend/tests/test_audit.py", - "qualified_symbol": "_authority_event_matrix", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 241 - }, { "capability": "unassigned_legacy_auth", "content_sha256": "1a0a9f3e2be6965e29f76aa74272ccfa2fe4b99e4e0c3bde5ec8ba2c374b369b", - "end_line": 10610, + "end_line": 10534, "hard_limit": 100, "kind": "test_helper", "observed_lines": 169, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10442 + "start_line": 10366 } ], "exceptions": [], diff --git a/.ci/auth-boundaries/assertion-maps/WS-QUAL-003-12.json b/.ci/auth-boundaries/assertion-maps/WS-QUAL-003-12.json index c4f093623..b0488fd6c 100644 --- a/.ci/auth-boundaries/assertion-maps/WS-QUAL-003-12.json +++ b/.ci/auth-boundaries/assertion-maps/WS-QUAL-003-12.json @@ -186,13 +186,13 @@ {"invariant_category": "actor_authorization_lock_rejects_disappearance_and_identity_drift", "new_test_node": "tests/actors/test_authorization_locks.py::test_actor_authorization_lock_returns_exact_locked_rows", "old_assertion_id": "assertion:538:538:5571a51e423aef29869fa0874d9520eb5deb79d9eb3d456d0c4966f19f35125b", "old_content_sha256": "5571a51e423aef29869fa0874d9520eb5deb79d9eb3d456d0c4966f19f35125b", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [538, 538], "old_test_node": "tests/test_actors.py::test_actor_authorization_lock_rejects_disappearance_and_identity_drift", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "actor_authorization_lock_rejects_disappearance_and_identity_drift", "new_test_node": "tests/actors/test_authorization_locks.py::test_actor_authorization_lock_rejects_disappeared_rows", "old_assertion_id": "assertion:512:512:a444992a367f32ffd9cf23a4dd15746411666dd8a89d6dc10b34644e176eabec", "old_content_sha256": "a444992a367f32ffd9cf23a4dd15746411666dd8a89d6dc10b34644e176eabec", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [512, 512], "old_test_node": "tests/test_actors.py::test_actor_authorization_lock_rejects_disappearance_and_identity_drift", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "actor_authorization_lock_rejects_disappearance_and_identity_drift", "new_test_node": "tests/actors/test_authorization_locks.py::test_actor_authorization_lock_rejects_disappeared_rows", "old_assertion_id": "assertion:514:514:94c31ee89bb0387b40b2149655dd656652f2f87fd502d49c0ad519e069f78512", "old_content_sha256": "94c31ee89bb0387b40b2149655dd656652f2f87fd502d49c0ad519e069f78512", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [514, 514], "old_test_node": "tests/test_actors.py::test_actor_authorization_lock_rejects_disappearance_and_identity_drift", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "active_human_write_actor_revalidates_exact_profile_then_link", "new_test_node": "tests/actors/test_authorization_locks.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "old_assertion_id": "assertion:580:580:c34c0095e350c508f3dfe1df784fa2c4ab9a60a2c5d1b6f00567654ff0c3144e", "old_content_sha256": "c34c0095e350c508f3dfe1df784fa2c4ab9a60a2c5d1b6f00567654ff0c3144e", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [580, 580], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "active_human_write_actor_revalidates_exact_profile_then_link", "new_test_node": "tests/actors/test_authorization_locks.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "old_assertion_id": "assertion:572:572:cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_content_sha256": "cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [572, 572], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "active_human_write_actor_revalidates_exact_profile_then_link", "new_test_node": "tests/actors/test_authorization_locks.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "old_assertion_id": "assertion:581:584:3ac425d0faab5877533f914af9269d735a6ccee270d38b8660b7c1f112d05c75", "old_content_sha256": "3ac425d0faab5877533f914af9269d735a6ccee270d38b8660b7c1f112d05c75", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [581, 584], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "active_human_write_actor_revalidates_exact_profile_then_link", "new_test_node": "tests/actors/test_authorization_locks.py::test_active_human_write_actor_rejects_ineligible_identity", "old_assertion_id": "assertion:653:653:dcaf806273acff21e860db641b5985fc48603f62aa83933c31e1deb6bee9cb58", "old_content_sha256": "dcaf806273acff21e860db641b5985fc48603f62aa83933c31e1deb6bee9cb58", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [653, 653], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": "preserved", "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "active_human_write_actor_revalidates_exact_profile_then_link", "new_test_node": "tests/actors/test_authorization_locks.py::test_active_human_write_actor_rejects_unavailable_rows", "old_assertion_id": "assertion:608:608:3caa0493665cfa614699911623e39d2755c90e829b651e9209bc4f619ffd9b21", "old_content_sha256": "3caa0493665cfa614699911623e39d2755c90e829b651e9209bc4f619ffd9b21", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [608, 608], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "active_human_write_actor_revalidates_exact_profile_then_link", "new_test_node": "tests/actors/test_authorization_locks.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "old_assertion_id": "assertion:567:567:cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_content_sha256": "cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [567, 567], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "active_human_write_actor_revalidates_exact_profile_then_link", "new_test_node": "tests/actors/test_authorization_locks.py::test_active_human_write_actor_rejects_ineligible_identity", "old_assertion_id": "assertion:643:646:9126b5fd738860e16efdb583b58f0d5cf171dc813b0bc1fb57b997607ebb3eda", "old_content_sha256": "9126b5fd738860e16efdb583b58f0d5cf171dc813b0bc1fb57b997607ebb3eda", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [643, 646], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": "preserved", "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, + {"invariant_category": "retired_contributor_wrapper_replaced_by_canonical_actor_resolution_and_prepared_authority", "new_test_node": "tests/authorization/task_authority/test_prepared.py::test_canonical_identity_selectors_and_missing_row_denials", "old_assertion_id": "assertion:580:580:c34c0095e350c508f3dfe1df784fa2c4ab9a60a2c5d1b6f00567654ff0c3144e", "old_content_sha256": "c34c0095e350c508f3dfe1df784fa2c4ab9a60a2c5d1b6f00567654ff0c3144e", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [580, 580], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "concurrency": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "denial_side_effects": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "evidence": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "lock_order": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "replay": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "revocation": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "transaction_ownership": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}}, "target_layer": "service"}, + {"invariant_category": "retired_contributor_wrapper_replaced_by_canonical_actor_resolution_and_prepared_authority", "new_test_node": "tests/authorization/task_authority/test_prepared.py::test_canonical_identity_selectors_and_missing_row_denials", "old_assertion_id": "assertion:572:572:cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_content_sha256": "cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [572, 572], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "concurrency": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "denial_side_effects": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "evidence": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "lock_order": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "replay": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "revocation": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "transaction_ownership": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}}, "target_layer": "service"}, + {"invariant_category": "retired_contributor_wrapper_replaced_by_canonical_actor_resolution_and_prepared_authority", "new_test_node": "tests/authorization/task_authority/test_prepared.py::test_canonical_identity_selectors_and_missing_row_denials", "old_assertion_id": "assertion:581:584:3ac425d0faab5877533f914af9269d735a6ccee270d38b8660b7c1f112d05c75", "old_content_sha256": "3ac425d0faab5877533f914af9269d735a6ccee270d38b8660b7c1f112d05c75", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [581, 584], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "concurrency": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "denial_side_effects": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "evidence": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "lock_order": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "replay": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "revocation": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "transaction_ownership": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}}, "target_layer": "service"}, + {"invariant_category": "retired_contributor_wrapper_replaced_by_canonical_actor_resolution_and_prepared_authority", "new_test_node": "tests/authorization/task_authority/test_prepared.py::test_fresh_authority_denies_despite_active_request_snapshot", "old_assertion_id": "assertion:653:653:dcaf806273acff21e860db641b5985fc48603f62aa83933c31e1deb6bee9cb58", "old_content_sha256": "dcaf806273acff21e860db641b5985fc48603f62aa83933c31e1deb6bee9cb58", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [653, 653], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "concurrency": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "denial_side_effects": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "evidence": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "lock_order": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "replay": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "revocation": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "transaction_ownership": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}}, "target_layer": "service"}, + {"invariant_category": "retired_contributor_wrapper_replaced_by_canonical_actor_resolution_and_prepared_authority", "new_test_node": "tests/authorization/task_authority/test_prepared.py::test_canonical_identity_selectors_and_missing_row_denials", "old_assertion_id": "assertion:608:608:3caa0493665cfa614699911623e39d2755c90e829b651e9209bc4f619ffd9b21", "old_content_sha256": "3caa0493665cfa614699911623e39d2755c90e829b651e9209bc4f619ffd9b21", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [608, 608], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "concurrency": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "denial_side_effects": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "evidence": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "lock_order": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "replay": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "revocation": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "transaction_ownership": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}}, "target_layer": "service"}, + {"invariant_category": "retired_contributor_wrapper_replaced_by_canonical_actor_resolution_and_prepared_authority", "new_test_node": "tests/authorization/task_authority/test_prepared.py::test_canonical_identity_selectors_and_missing_row_denials", "old_assertion_id": "assertion:567:567:cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_content_sha256": "cdcf78ca39126f580a65aabb0485b8b11c1fbfce1192da8fd51ea6c5950f6903", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [567, 567], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "concurrency": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "denial_side_effects": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "evidence": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "lock_order": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "replay": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "revocation": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "transaction_ownership": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}}, "target_layer": "service"}, + {"invariant_category": "retired_contributor_wrapper_replaced_by_canonical_actor_resolution_and_prepared_authority", "new_test_node": "tests/authorization/task_authority/test_prepared.py::test_fresh_authority_denies_despite_active_request_snapshot", "old_assertion_id": "assertion:643:646:9126b5fd738860e16efdb583b58f0d5cf171dc813b0bc1fb57b997607ebb3eda", "old_content_sha256": "9126b5fd738860e16efdb583b58f0d5cf171dc813b0bc1fb57b997607ebb3eda", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [643, 646], "old_test_node": "tests/test_actors.py::test_active_human_write_actor_revalidates_exact_profile_then_link", "security_dimensions": {"concealment": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "concurrency": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "denial_side_effects": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "evidence": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "lock_order": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "replay": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "revocation": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}, "transaction_ownership": {"not_applicable_reason": "The exclusive ActorContext contributor wrapper is removed, including its issuer/subject selector and private exception shapes. The new test proves its named canonical AUTH boundary with explicit SQL/evidence doubles; ActorService resolution tests separately retain subject-kind rejection and PostgreSQL lifecycle races own serialization. This is not equivalent execution of the removed wrapper."}}, "target_layer": "service"}, {"invariant_category": "actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "new_test_node": "tests/actors/test_repository_contract.py::test_actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "old_assertion_id": "assertion:677:677:a82fa18ba9e2ef3176cf4c61a3bc2aef4750448f9ca1ebf52a9e3d2630d2fec6", "old_content_sha256": "a82fa18ba9e2ef3176cf4c61a3bc2aef4750448f9ca1ebf52a9e3d2630d2fec6", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [677, 677], "old_test_node": "tests/test_actors.py::test_actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "new_test_node": "tests/actors/test_repository_contract.py::test_actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "old_assertion_id": "assertion:681:681:a444992a367f32ffd9cf23a4dd15746411666dd8a89d6dc10b34644e176eabec", "old_content_sha256": "a444992a367f32ffd9cf23a4dd15746411666dd8a89d6dc10b34644e176eabec", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [681, 681], "old_test_node": "tests/test_actors.py::test_actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "new_test_node": "tests/actors/test_repository_contract.py::test_actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "old_assertion_id": "assertion:694:694:8c9c9027d68c0b045722b801076d6b8c7d918069dd1eb85eac6d8ea4f7099723", "old_content_sha256": "8c9c9027d68c0b045722b801076d6b8c7d918069dd1eb85eac6d8ea4f7099723", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [694, 694], "old_test_node": "tests/test_actors.py::test_actor_timestamp_touch_fails_closed_before_writes_on_missing_rows", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, @@ -282,11 +282,11 @@ {"invariant_category": "revocation_wins_synchronized_actor_update_recheck", "new_test_node": "tests/actors/test_self_api_lifecycle.py::test_revocation_wins_synchronized_actor_update_recheck", "old_assertion_id": "assertion:1203:1203:3243f3538084c288225770c3c8a8aa5420e21c20bda8bfb33a7b6b1742a9d5d5", "old_content_sha256": "3243f3538084c288225770c3c8a8aa5420e21c20bda8bfb33a7b6b1742a9d5d5", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1203, 1203], "old_test_node": "tests/test_actors.py::test_revocation_wins_synchronized_actor_update_recheck", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": "preserved", "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": "preserved", "transaction_ownership": "preserved"}, "target_layer": "integration"}, {"invariant_category": "revocation_wins_synchronized_actor_update_recheck", "new_test_node": "tests/actors/test_self_api_lifecycle.py::test_revocation_wins_synchronized_actor_update_recheck", "old_assertion_id": "assertion:1193:1193:b901008ed8197ce60dc4408169cf9d1b8d7c2e1a1de08d091ee7cd57e0e0ded5", "old_content_sha256": "b901008ed8197ce60dc4408169cf9d1b8d7c2e1a1de08d091ee7cd57e0e0ded5", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1193, 1193], "old_test_node": "tests/test_actors.py::test_revocation_wins_synchronized_actor_update_recheck", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": "preserved", "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": "preserved", "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": "preserved", "transaction_ownership": "preserved"}, "target_layer": "integration"}, {"invariant_category": "actor_api_accepts_verifier_identity_bounds", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_actor_api_accepts_verifier_identity_bounds", "old_assertion_id": "assertion:1224:1224:cf209785ef4cc3f97e55296d271f7a7a7c3bc87e88958a85193228a71dd7d03e", "old_content_sha256": "cf209785ef4cc3f97e55296d271f7a7a7c3bc87e88958a85193228a71dd7d03e", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1224, 1224], "old_test_node": "tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "actor_api_accepts_verifier_identity_bounds", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_legacy_provenance_accepts_verifier_identity_bounds", "old_assertion_id": "assertion:1231:1231:30a87a5c55b24af89b4c882a85867371b4b271598d48de7f67a7362cf1d69018", "old_content_sha256": "30a87a5c55b24af89b4c882a85867371b4b271598d48de7f67a7362cf1d69018", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1231, 1231], "old_test_node": "tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "actor_api_accepts_verifier_identity_bounds", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_legacy_provenance_accepts_verifier_identity_bounds", "old_assertion_id": "assertion:1238:1238:b5196cb5d67f1bf7c738fa4ab263e3282f4c045c63e062be009408d9c2f50ade", "old_content_sha256": "b5196cb5d67f1bf7c738fa4ab263e3282f4c045c63e062be009408d9c2f50ade", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1238, 1238], "old_test_node": "tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, + {"invariant_category":"retired_activation_provenance_replaced_by_canonical_admission_privacy","new_test_node":"tests/actors/test_identity_bounds_and_rate_controls.py::test_identity_bounds_preserve_private_canonical_provisioning_evidence","old_assertion_id":"assertion:1231:1231:30a87a5c55b24af89b4c882a85867371b4b271598d48de7f67a7362cf1d69018","old_content_sha256":"30a87a5c55b24af89b4c882a85867371b4b271598d48de7f67a7362cf1d69018","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1231,1231],"old_test_node":"tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds","security_dimensions":{"concealment":{"not_applicable_reason":"This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."},"concurrency":{"not_applicable_reason":"This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."},"denial_side_effects":{"not_applicable_reason":"This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."},"evidence":{"not_applicable_reason":"The removed activation event is not preserved. Canonical ActorProfileProvisioned evidence deliberately omits external identity and token claims; the replacement proves that privacy boundary after admitting maximum-length identity fields."},"lock_order":{"not_applicable_reason":"This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."},"replay":{"not_applicable_reason":"This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."},"revocation":{"not_applicable_reason":"This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."},"transaction_ownership":{"not_applicable_reason":"This behavior does not claim PostgreSQL transaction ownership or rollback."}},"target_layer":"integration"}, + {"invariant_category":"retired_activation_provenance_replaced_by_canonical_admission_privacy","new_test_node":"tests/actors/test_identity_bounds_and_rate_controls.py::test_identity_bounds_preserve_private_canonical_provisioning_evidence","old_assertion_id":"assertion:1238:1238:b5196cb5d67f1bf7c738fa4ab263e3282f4c045c63e062be009408d9c2f50ade","old_content_sha256":"b5196cb5d67f1bf7c738fa4ab263e3282f4c045c63e062be009408d9c2f50ade","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1238,1238],"old_test_node":"tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds","security_dimensions":{"concealment":{"not_applicable_reason":"This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."},"concurrency":{"not_applicable_reason":"This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."},"denial_side_effects":{"not_applicable_reason":"This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."},"evidence":{"not_applicable_reason":"The removed activation event is not preserved. Canonical ActorProfileProvisioned evidence deliberately omits external identity and token claims; the replacement proves that privacy boundary after admitting maximum-length identity fields."},"lock_order":{"not_applicable_reason":"This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."},"replay":{"not_applicable_reason":"This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."},"revocation":{"not_applicable_reason":"This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."},"transaction_ownership":{"not_applicable_reason":"This behavior does not claim PostgreSQL transaction ownership or rollback."}},"target_layer":"integration"}, {"invariant_category": "actor_api_accepts_verifier_identity_bounds", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_actor_api_accepts_verifier_identity_bounds", "old_assertion_id": "assertion:1216:1216:d929d45177cc0b56b2951e6705c8c88b4cb54a9af26036c0c17cae5b30c1219b", "old_content_sha256": "d929d45177cc0b56b2951e6705c8c88b4cb54a9af26036c0c17cae5b30c1219b", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1216, 1216], "old_test_node": "tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "actor_api_accepts_verifier_identity_bounds", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_legacy_provenance_accepts_verifier_identity_bounds", "old_assertion_id": "assertion:1239:1239:e04d551870498be95c301cfb843793dc02a38660404f99884ef997c2e745477b", "old_content_sha256": "e04d551870498be95c301cfb843793dc02a38660404f99884ef997c2e745477b", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1239, 1239], "old_test_node": "tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "actor_api_accepts_verifier_identity_bounds", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_legacy_provenance_accepts_verifier_identity_bounds", "old_assertion_id": "assertion:1240:1240:53a7e089e324434cbf0a534620408385929170b0c4d5a408cf25c26393c6097a", "old_content_sha256": "53a7e089e324434cbf0a534620408385929170b0c4d5a408cf25c26393c6097a", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1240, 1240], "old_test_node": "tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, + {"invariant_category":"retired_activation_provenance_replaced_by_canonical_admission_privacy","new_test_node":"tests/actors/test_identity_bounds_and_rate_controls.py::test_identity_bounds_preserve_private_canonical_provisioning_evidence","old_assertion_id":"assertion:1239:1239:e04d551870498be95c301cfb843793dc02a38660404f99884ef997c2e745477b","old_content_sha256":"e04d551870498be95c301cfb843793dc02a38660404f99884ef997c2e745477b","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1239,1239],"old_test_node":"tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds","security_dimensions":{"concealment":{"not_applicable_reason":"This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."},"concurrency":{"not_applicable_reason":"This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."},"denial_side_effects":{"not_applicable_reason":"This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."},"evidence":{"not_applicable_reason":"The removed activation event is not preserved. Canonical ActorProfileProvisioned evidence deliberately omits external identity and token claims; the replacement proves that privacy boundary after admitting maximum-length identity fields."},"lock_order":{"not_applicable_reason":"This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."},"replay":{"not_applicable_reason":"This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."},"revocation":{"not_applicable_reason":"This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."},"transaction_ownership":{"not_applicable_reason":"This behavior does not claim PostgreSQL transaction ownership or rollback."}},"target_layer":"integration"}, + {"invariant_category":"retired_activation_provenance_replaced_by_canonical_admission_privacy","new_test_node":"tests/actors/test_identity_bounds_and_rate_controls.py::test_identity_bounds_preserve_private_canonical_provisioning_evidence","old_assertion_id":"assertion:1240:1240:53a7e089e324434cbf0a534620408385929170b0c4d5a408cf25c26393c6097a","old_content_sha256":"53a7e089e324434cbf0a534620408385929170b0c4d5a408cf25c26393c6097a","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1240,1240],"old_test_node":"tests/test_actors.py::test_actor_api_accepts_verifier_identity_bounds","security_dimensions":{"concealment":{"not_applicable_reason":"This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."},"concurrency":{"not_applicable_reason":"This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."},"denial_side_effects":{"not_applicable_reason":"This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."},"evidence":{"not_applicable_reason":"The removed activation event is not preserved. Canonical ActorProfileProvisioned evidence deliberately omits external identity and token claims; the replacement proves that privacy boundary after admitting maximum-length identity fields."},"lock_order":{"not_applicable_reason":"This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."},"replay":{"not_applicable_reason":"This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."},"revocation":{"not_applicable_reason":"This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."},"transaction_ownership":{"not_applicable_reason":"This behavior does not claim PostgreSQL transaction ownership or rollback."}},"target_layer":"integration"}, {"invariant_category": "verified_identity_rejects_values_above_persisted_provenance_bound", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_verified_identity_rejects_values_above_persisted_provenance_bound", "old_assertion_id": "assertion:1246:1246:a5515b434626a180cda8064b8c9465f6b5e5173f3cee81f1898c91e4f36b5a83", "old_content_sha256": "a5515b434626a180cda8064b8c9465f6b5e5173f3cee81f1898c91e4f36b5a83", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1246, 1246], "old_test_node": "tests/test_actors.py::test_verified_identity_rejects_values_above_persisted_provenance_bound", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "domain"}, {"invariant_category": "verified_identity_rejects_values_above_persisted_provenance_bound", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_verified_identity_rejects_values_above_persisted_provenance_bound", "old_assertion_id": "assertion:1244:1244:a5515b434626a180cda8064b8c9465f6b5e5173f3cee81f1898c91e4f36b5a83", "old_content_sha256": "a5515b434626a180cda8064b8c9465f6b5e5173f3cee81f1898c91e4f36b5a83", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1244, 1244], "old_test_node": "tests/test_actors.py::test_verified_identity_rejects_values_above_persisted_provenance_bound", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "domain"}, {"invariant_category": "first_access_rate_limit_denies_without_actor_write", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_first_access_rate_limit_denies_without_actor_write", "old_assertion_id": "assertion:1276:1276:b1d673d12e27db89fe3738fdf519b1ced578319e7985984adef0d66eca07c106", "old_content_sha256": "b1d673d12e27db89fe3738fdf519b1ced578319e7985984adef0d66eca07c106", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1276, 1276], "old_test_node": "tests/test_actors.py::test_first_access_rate_limit_denies_without_actor_write", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": "preserved", "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, @@ -297,41 +297,41 @@ {"invariant_category": "first_access_rate_control_unavailable_fails_closed", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_first_access_rate_control_unavailable_fails_closed", "old_assertion_id": "assertion:1297:1306:683a99d1ad51ed2aa0b09fed82e22e5c327f55d11fa96ddaedc9e4188b6cb828", "old_content_sha256": "683a99d1ad51ed2aa0b09fed82e22e5c327f55d11fa96ddaedc9e4188b6cb828", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1297, 1306], "old_test_node": "tests/test_actors.py::test_first_access_rate_control_unavailable_fails_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, {"invariant_category": "first_access_rate_control_unavailable_fails_closed", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_first_access_rate_control_unavailable_fails_closed", "old_assertion_id": "assertion:1293:1293:4b2339ca728ade8532900610ea192fce6ca19e25bf4cdba8fdca657c1ad4e155", "old_content_sha256": "4b2339ca728ade8532900610ea192fce6ca19e25bf4cdba8fdca657c1ad4e155", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1293, 1293], "old_test_node": "tests/test_actors.py::test_first_access_rate_control_unavailable_fails_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, {"invariant_category": "first_access_rate_control_unavailable_fails_closed", "new_test_node": "tests/actors/test_identity_bounds_and_rate_controls.py::test_first_access_rate_control_unavailable_fails_closed", "old_assertion_id": "assertion:1295:1295:b1d673d12e27db89fe3738fdf519b1ced578319e7985984adef0d66eca07c106", "old_content_sha256": "b1d673d12e27db89fe3738fdf519b1ced578319e7985984adef0d66eca07c106", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1295, 1295], "old_test_node": "tests/test_actors.py::test_first_access_rate_control_unavailable_fails_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1323:1323:1bc17ef16e4a69e0a1c47f3e9d74b191de892dfc92d003319330ce47d367c5a7", "old_content_sha256": "1bc17ef16e4a69e0a1c47f3e9d74b191de892dfc92d003319330ce47d367c5a7", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1323, 1323], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1326:1326:5f1f044ffda1fc117b1852aad205c03e7b429288a8cb137b1feaa4bead7cecd2", "old_content_sha256": "5f1f044ffda1fc117b1852aad205c03e7b429288a8cb137b1feaa4bead7cecd2", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1326, 1326], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1333:1333:2daff84dca46e556f0a0bce0e3ef464c4d1e1acc207c3965f13020139844ca69", "old_content_sha256": "2daff84dca46e556f0a0bce0e3ef464c4d1e1acc207c3965f13020139844ca69", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1333, 1333], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1324:1324:ac2c18c2b2011cc87d94281ff2f51e44d055b6ce4e4f6c17c955c187d364b650", "old_content_sha256": "ac2c18c2b2011cc87d94281ff2f51e44d055b6ce4e4f6c17c955c187d364b650", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1324, 1324], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1332:1332:09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e", "old_content_sha256": "09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1332, 1332], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1342:1342:80f56bbf6d3fdf43b7c7356d197b6175e53eef57f8b640d41547d8dcf4d70cbc", "old_content_sha256": "80f56bbf6d3fdf43b7c7356d197b6175e53eef57f8b640d41547d8dcf4d70cbc", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1342, 1342], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1343:1343:c27b3d8dd3c4c6aaa01a045239ce142f025d0403e51cbc5141dc88fc3bfa53c0", "old_content_sha256": "c27b3d8dd3c4c6aaa01a045239ce142f025d0403e51cbc5141dc88fc3bfa53c0", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1343, 1343], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1339:1339:254dc04836930b383ec1dfbcc6e46161879663a95339ed878f737d73a06855f0", "old_content_sha256": "254dc04836930b383ec1dfbcc6e46161879663a95339ed878f737d73a06855f0", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1339, 1339], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1340:1340:ae8b81454cac7451b1959a19e1346e98c5a7fdf620c398e1306e9c20ee276baa", "old_content_sha256": "ae8b81454cac7451b1959a19e1346e98c5a7fdf620c398e1306e9c20ee276baa", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1340, 1340], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1344:1349:11b8b803a654b28c99b10c32b5d91a655508f406329ed63b6be3b7e120c633b8", "old_content_sha256": "11b8b803a654b28c99b10c32b5d91a655508f406329ed63b6be3b7e120c633b8", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1344, 1349], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "legacy_activation_writes_only_compatibility_metadata", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_legacy_activation_writes_only_compatibility_metadata", "old_assertion_id": "assertion:1341:1341:a95e145d791a0e8ff0564d50b4d8e57422eaf43978cc17da1c62763518493823", "old_content_sha256": "a95e145d791a0e8ff0564d50b4d8e57422eaf43978cc17da1c62763518493823", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1341, 1341], "old_test_node": "tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "repeated_legacy_activation_updates_one_row_and_audits_only_changes", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "old_assertion_id": "assertion:1380:1387:0089247c5cadec2d28d32926641530191445f4f0ffd7d82dbf0bc7bc7f70b82c", "old_content_sha256": "0089247c5cadec2d28d32926641530191445f4f0ffd7d82dbf0bc7bc7f70b82c", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1380, 1387], "old_test_node": "tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "repeated_legacy_activation_updates_one_row_and_audits_only_changes", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "old_assertion_id": "assertion:1377:1377:c084e40dcb917869e8beee9d15b473787ff48ed1ef1171b93f3bd6ef7f438812", "old_content_sha256": "c084e40dcb917869e8beee9d15b473787ff48ed1ef1171b93f3bd6ef7f438812", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1377, 1377], "old_test_node": "tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "repeated_legacy_activation_updates_one_row_and_audits_only_changes", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "old_assertion_id": "assertion:1378:1378:99f410f7ae70007c92dd4f4ecf802c620a19e015a49c3748ae4311cfdb150a48", "old_content_sha256": "99f410f7ae70007c92dd4f4ecf802c620a19e015a49c3748ae4311cfdb150a48", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1378, 1378], "old_test_node": "tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "repeated_legacy_activation_updates_one_row_and_audits_only_changes", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "old_assertion_id": "assertion:1376:1376:474f541c7ebf366c0a6da4da57d07f1d645c26ba059b665c4af627afb0090cac", "old_content_sha256": "474f541c7ebf366c0a6da4da57d07f1d645c26ba059b665c4af627afb0090cac", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1376, 1376], "old_test_node": "tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "repeated_legacy_activation_updates_one_row_and_audits_only_changes", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "old_assertion_id": "assertion:1388:1398:102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626", "old_content_sha256": "102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1388, 1398], "old_test_node": "tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, - {"invariant_category": "concurrent_legacy_activation_serializes_payloads_and_actual_audits", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "old_assertion_id": "assertion:1451:1451:9bb4aa2054e2bd48f9de95de09385be3fd05036423e64401e6173c1792ba3490", "old_content_sha256": "9bb4aa2054e2bd48f9de95de09385be3fd05036423e64401e6173c1792ba3490", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1451, 1451], "old_test_node": "tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": "preserved"}, "target_layer": "integration"}, - {"invariant_category": "concurrent_legacy_activation_serializes_payloads_and_actual_audits", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "old_assertion_id": "assertion:1449:1449:8ec5feeb32d4e58eb289329400b1651acf8e520789295b03d184a3fd87c52a31", "old_content_sha256": "8ec5feeb32d4e58eb289329400b1651acf8e520789295b03d184a3fd87c52a31", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1449, 1449], "old_test_node": "tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": "preserved"}, "target_layer": "integration"}, - {"invariant_category": "concurrent_legacy_activation_serializes_payloads_and_actual_audits", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "old_assertion_id": "assertion:1460:1470:102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626", "old_content_sha256": "102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1460, 1470], "old_test_node": "tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": "preserved"}, "target_layer": "integration"}, - {"invariant_category": "concurrent_legacy_activation_serializes_payloads_and_actual_audits", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "old_assertion_id": "assertion:1450:1450:a8f3884e19adf7516cb1a05053bb21a4f7a6213c828ba1d66da8b2cd719d6a1e", "old_content_sha256": "a8f3884e19adf7516cb1a05053bb21a4f7a6213c828ba1d66da8b2cd719d6a1e", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1450, 1450], "old_test_node": "tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": "preserved"}, "target_layer": "integration"}, - {"invariant_category": "concurrent_legacy_activation_serializes_payloads_and_actual_audits", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "old_assertion_id": "assertion:1441:1441:e9bb13f4c27aad0d9ee83578aa9651933ea20127c246c67916217c122f617e72", "old_content_sha256": "e9bb13f4c27aad0d9ee83578aa9651933ea20127c246c67916217c122f617e72", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1441, 1441], "old_test_node": "tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": "preserved"}, "target_layer": "integration"}, - {"invariant_category": "concurrent_legacy_activation_serializes_payloads_and_actual_audits", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "old_assertion_id": "assertion:1459:1459:dd0e555f668b045c20cd9ae684399e9380932220ce9ef245181e7c53c58c3afe", "old_content_sha256": "dd0e555f668b045c20cd9ae684399e9380932220ce9ef245181e7c53c58c3afe", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1459, 1459], "old_test_node": "tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": "preserved"}, "target_layer": "integration"}, - {"invariant_category": "concurrent_legacy_activation_serializes_payloads_and_actual_audits", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "old_assertion_id": "assertion:1458:1458:09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e", "old_content_sha256": "09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1458, 1458], "old_test_node": "tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": "preserved", "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": "preserved", "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": "preserved"}, "target_layer": "integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1323:1323:1bc17ef16e4a69e0a1c47f3e9d74b191de892dfc92d003319330ce47d367c5a7","old_content_sha256":"1bc17ef16e4a69e0a1c47f3e9d74b191de892dfc92d003319330ce47d367c5a7","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1323,1323],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1326:1326:5f1f044ffda1fc117b1852aad205c03e7b429288a8cb137b1feaa4bead7cecd2","old_content_sha256":"5f1f044ffda1fc117b1852aad205c03e7b429288a8cb137b1feaa4bead7cecd2","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1326,1326],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1333:1333:2daff84dca46e556f0a0bce0e3ef464c4d1e1acc207c3965f13020139844ca69","old_content_sha256":"2daff84dca46e556f0a0bce0e3ef464c4d1e1acc207c3965f13020139844ca69","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1333,1333],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1324:1324:ac2c18c2b2011cc87d94281ff2f51e44d055b6ce4e4f6c17c955c187d364b650","old_content_sha256":"ac2c18c2b2011cc87d94281ff2f51e44d055b6ce4e4f6c17c955c187d364b650","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1324,1324],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1332:1332:09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e","old_content_sha256":"09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1332,1332],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1342:1342:80f56bbf6d3fdf43b7c7356d197b6175e53eef57f8b640d41547d8dcf4d70cbc","old_content_sha256":"80f56bbf6d3fdf43b7c7356d197b6175e53eef57f8b640d41547d8dcf4d70cbc","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1342,1342],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1343:1343:c27b3d8dd3c4c6aaa01a045239ce142f025d0403e51cbc5141dc88fc3bfa53c0","old_content_sha256":"c27b3d8dd3c4c6aaa01a045239ce142f025d0403e51cbc5141dc88fc3bfa53c0","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1343,1343],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1339:1339:254dc04836930b383ec1dfbcc6e46161879663a95339ed878f737d73a06855f0","old_content_sha256":"254dc04836930b383ec1dfbcc6e46161879663a95339ed878f737d73a06855f0","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1339,1339],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1340:1340:ae8b81454cac7451b1959a19e1346e98c5a7fdf620c398e1306e9c20ee276baa","old_content_sha256":"ae8b81454cac7451b1959a19e1346e98c5a7fdf620c398e1306e9c20ee276baa","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1340,1340],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1344:1349:11b8b803a654b28c99b10c32b5d91a655508f406329ed63b6be3b7e120c633b8","old_content_sha256":"11b8b803a654b28c99b10c32b5d91a655508f406329ed63b6be3b7e120c633b8","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1344,1349],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1341:1341:a95e145d791a0e8ff0564d50b4d8e57422eaf43978cc17da1c62763518493823","old_content_sha256":"a95e145d791a0e8ff0564d50b4d8e57422eaf43978cc17da1c62763518493823","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1341,1341],"old_test_node":"tests/test_actors.py::test_legacy_activation_writes_only_compatibility_metadata","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1380:1387:0089247c5cadec2d28d32926641530191445f4f0ffd7d82dbf0bc7bc7f70b82c","old_content_sha256":"0089247c5cadec2d28d32926641530191445f4f0ffd7d82dbf0bc7bc7f70b82c","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1380,1387],"old_test_node":"tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1377:1377:c084e40dcb917869e8beee9d15b473787ff48ed1ef1171b93f3bd6ef7f438812","old_content_sha256":"c084e40dcb917869e8beee9d15b473787ff48ed1ef1171b93f3bd6ef7f438812","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1377,1377],"old_test_node":"tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1378:1378:99f410f7ae70007c92dd4f4ecf802c620a19e015a49c3748ae4311cfdb150a48","old_content_sha256":"99f410f7ae70007c92dd4f4ecf802c620a19e015a49c3748ae4311cfdb150a48","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1378,1378],"old_test_node":"tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1376:1376:474f541c7ebf366c0a6da4da57d07f1d645c26ba059b665c4af627afb0090cac","old_content_sha256":"474f541c7ebf366c0a6da4da57d07f1d645c26ba059b665c4af627afb0090cac","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1376,1376],"old_test_node":"tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1388:1398:102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626","old_content_sha256":"102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1388,1398],"old_test_node":"tests/test_actors.py::test_repeated_legacy_activation_updates_one_row_and_audits_only_changes","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1451:1451:9bb4aa2054e2bd48f9de95de09385be3fd05036423e64401e6173c1792ba3490","old_content_sha256":"9bb4aa2054e2bd48f9de95de09385be3fd05036423e64401e6173c1792ba3490","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1451,1451],"old_test_node":"tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1449:1449:8ec5feeb32d4e58eb289329400b1651acf8e520789295b03d184a3fd87c52a31","old_content_sha256":"8ec5feeb32d4e58eb289329400b1651acf8e520789295b03d184a3fd87c52a31","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1449,1449],"old_test_node":"tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1460:1470:102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626","old_content_sha256":"102ec7d46b8851457d04345a12e35c88eff373fcc2b2dc53c37e154517447626","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1460,1470],"old_test_node":"tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1450:1450:a8f3884e19adf7516cb1a05053bb21a4f7a6213c828ba1d66da8b2cd719d6a1e","old_content_sha256":"a8f3884e19adf7516cb1a05053bb21a4f7a6213c828ba1d66da8b2cd719d6a1e","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1450,1450],"old_test_node":"tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1441:1441:e9bb13f4c27aad0d9ee83578aa9651933ea20127c246c67916217c122f617e72","old_content_sha256":"e9bb13f4c27aad0d9ee83578aa9651933ea20127c246c67916217c122f617e72","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1441,1441],"old_test_node":"tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1459:1459:dd0e555f668b045c20cd9ae684399e9380932220ce9ef245181e7c53c58c3afe","old_content_sha256":"dd0e555f668b045c20cd9ae684399e9380932220ce9ef245181e7c53c58c3afe","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1459,1459],"old_test_node":"tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1458:1458:09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e","old_content_sha256":"09692673b502329b1362629401f580e1243a8bb29a87ac36f060e389cfb8936e","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1458,1458],"old_test_node":"tests/test_actors.py::test_concurrent_legacy_activation_serializes_payloads_and_actual_audits","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_known_service_admission_preserves_verification_timestamps", "old_assertion_id": "assertion:1558:1558:0c584238d9b55b06351a9c1893332deaffebf4d2058630aa88154e167ff4deb8", "old_content_sha256": "0c584238d9b55b06351a9c1893332deaffebf4d2058630aa88154e167ff4deb8", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1558, 1558], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_known_service_admission_preserves_verification_timestamps", "old_assertion_id": "assertion:1554:1554:788ec97d1f6d89b19873d6f90573e911c4b25c740a9ee404b9aed8ad7fe4d02f", "old_content_sha256": "788ec97d1f6d89b19873d6f90573e911c4b25c740a9ee404b9aed8ad7fe4d02f", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1554, 1554], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_deactivated_actor_denies_direct_self_update", "old_assertion_id": "assertion:1505:1505:b893af04a08d29a02adfffd1c1b427f5894348d06e10ef6578bfec4680d73cd5", "old_content_sha256": "b893af04a08d29a02adfffd1c1b427f5894348d06e10ef6578bfec4680d73cd5", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1505, 1505], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": "preserved", "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_disabled_legacy_eligibility_is_not_reactivated", "old_assertion_id": "assertion:1587:1587:0d2f229e105a5d13a632902a4e93daecef38b42a7f8ecd8439af0ac37b86bb76", "old_content_sha256": "0d2f229e105a5d13a632902a4e93daecef38b42a7f8ecd8439af0ac37b86bb76", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1587, 1587], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1587:1587:0d2f229e105a5d13a632902a4e93daecef38b42a7f8ecd8439af0ac37b86bb76","old_content_sha256":"0d2f229e105a5d13a632902a4e93daecef38b42a7f8ecd8439af0ac37b86bb76","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1587,1587],"old_test_node":"tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_unknown_service_creates_nothing", "old_assertion_id": "assertion:1539:1539:453d39c627ae0ce90a63aa5369bd88649729742a0aba9ab1f757071f06625cba", "old_content_sha256": "453d39c627ae0ce90a63aa5369bd88649729742a0aba9ab1f757071f06625cba", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1539, 1539], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": "preserved", "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_disabled_legacy_eligibility_is_not_reactivated", "old_assertion_id": "assertion:1594:1594:52552758d6ad605ca08e574832cf6934b76df59813699c848e5364bf33f31aec", "old_content_sha256": "52552758d6ad605ca08e574832cf6934b76df59813699c848e5364bf33f31aec", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1594, 1594], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1594:1594:52552758d6ad605ca08e574832cf6934b76df59813699c848e5364bf33f31aec","old_content_sha256":"52552758d6ad605ca08e574832cf6934b76df59813699c848e5364bf33f31aec","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1594,1594],"old_test_node":"tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_known_service_cannot_use_human_authorization_entry", "old_assertion_id": "assertion:1545:1545:453d39c627ae0ce90a63aa5369bd88649729742a0aba9ab1f757071f06625cba", "old_content_sha256": "453d39c627ae0ce90a63aa5369bd88649729742a0aba9ab1f757071f06625cba", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1545, 1545], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_known_service_admission_preserves_verification_timestamps", "old_assertion_id": "assertion:1552:1552:a3c1c67861caace7cea5520f477b93af79f5f8372251f4a8f5917f013682245c", "old_content_sha256": "a3c1c67861caace7cea5520f477b93af79f5f8372251f4a8f5917f013682245c", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1552, 1552], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_known_service_admission_preserves_verification_timestamps", "old_assertion_id": "assertion:1559:1559:4e02602e8438c812776a62eea87d4553c00e1ea35242dd2050c371dfcb5c55b1", "old_content_sha256": "4e02602e8438c812776a62eea87d4553c00e1ea35242dd2050c371dfcb5c55b1", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1559, 1559], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_revoked_identity_denies_verified_actor_lookup", "old_assertion_id": "assertion:1490:1490:7416503581358109b404954830cf01ed7608f9fbdb939262bc0add101088ecd7", "old_content_sha256": "7416503581358109b404954830cf01ed7608f9fbdb939262bc0add101088ecd7", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1490, 1490], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": "preserved", "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_known_service_admission_preserves_verification_timestamps", "old_assertion_id": "assertion:1556:1556:bc091ac7d28a098b51b75d5fb15326f525585004c772126ed9e2b8ba4fb7cd7e", "old_content_sha256": "bc091ac7d28a098b51b75d5fb15326f525585004c772126ed9e2b8ba4fb7cd7e", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1556, 1556], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"}, - {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_legacy_eligibility_postgresql.py::test_disabled_legacy_eligibility_is_not_reactivated", "old_assertion_id": "assertion:1593:1593:fc7561654d26cec9f4f3b10a110c71c31e7e6396efa3558d1e5a4eaa523a0edf", "old_content_sha256": "fc7561654d26cec9f4f3b10a110c71c31e7e6396efa3558d1e5a4eaa523a0edf", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1593, 1593], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "integration"}, + {"invariant_category":"retired_self_activation_replaced_by_absent_route_without_registry_or_grant_writes","new_test_node":"tests/authorization/task_authority/test_task_commands.py::test_retired_worker_endpoint_cannot_admit_or_self_authorize","old_assertion_id":"assertion:1593:1593:fc7561654d26cec9f4f3b10a110c71c31e7e6396efa3558d1e5a4eaa523a0edf","old_content_sha256":"fc7561654d26cec9f4f3b10a110c71c31e7e6396efa3558d1e5a4eaa523a0edf","old_revision":"ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6","old_source_span":[1593,1593],"old_test_node":"tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed","security_dimensions":{"concealment":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"concurrency":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"denial_side_effects":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"evidence":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"lock_order":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"replay":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"revocation":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."},"transaction_ownership":{"not_applicable_reason":"Self-activation is intentionally removed by task-project-grant-authorization; this historical assertion is retired, not behaviorally preserved. The replacement proves the route is absent and creates no registry or grant rows; it does not certify the former activation operation."}},"target_layer":"integration"}, {"invariant_category": "existing_actor_and_legacy_negative_states_fail_closed", "new_test_node": "tests/actors/test_resolution_service.py::test_known_service_admission_preserves_verification_timestamps", "old_assertion_id": "assertion:1557:1557:a1d785e8d9f3159b9edaa9f85e840671104fe526a42163b5718321fd88f53477", "old_content_sha256": "a1d785e8d9f3159b9edaa9f85e840671104fe526a42163b5718321fd88f53477", "old_revision": "ca86fb38e8d9d4fb4dafc9cd0256fa0aa6ed6de6", "old_source_span": [1557, 1557], "old_test_node": "tests/test_actors.py::test_existing_actor_and_legacy_negative_states_fail_closed", "security_dimensions": {"concealment": {"not_applicable_reason": "This assertion does not claim response redaction; bounded admin views and verifier error tests own that proof."}, "concurrency": {"not_applicable_reason": "This mapped behavior is sequential; concurrency is separately proven by the named JWKS or PostgreSQL race tests."}, "denial_side_effects": {"not_applicable_reason": "This assertion proves its named value or exception, not a separate absence-of-side-effects boundary."}, "evidence": {"not_applicable_reason": "This behavior does not claim persisted authorization or lifecycle evidence; actor transaction tests own that proof."}, "lock_order": {"not_applicable_reason": "This assertion does not claim owner-lock ordering; exact actor lock selectors and PostgreSQL races have separate proof."}, "replay": {"not_applicable_reason": "This test does not claim durable operation replay; cache reuse and repeated identity access are their own named behaviors."}, "revocation": {"not_applicable_reason": "This behavior does not change identity revocation state; revoked-link and synchronized revocation tests own that proof."}, "transaction_ownership": {"not_applicable_reason": "This behavior does not claim PostgreSQL transaction ownership or rollback."}}, "target_layer": "service"} ] } diff --git a/.ci/auth-boundaries/assertion-maps/task-project-grant-authorization.json b/.ci/auth-boundaries/assertion-maps/task-project-grant-authorization.json new file mode 100644 index 000000000..35e034f48 --- /dev/null +++ b/.ci/auth-boundaries/assertion-maps/task-project-grant-authorization.json @@ -0,0 +1,53 @@ +{ + "chunk_id": "task-project-grant-authorization", + "schema": "workstream.auth-assertion-map.v1", + "mappings": [ + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4810:4810:138c84d8cbf16f29599dd57ae967d65574c1d4b53de8013ccc0f5d4509503be4","old_content_sha256":"138c84d8cbf16f29599dd57ae967d65574c1d4b53de8013ccc0f5d4509503be4","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4810,4810],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4811:4811:b675b19bce9c4d42d1d5fd05ea200bc9bfa07c6d872eec9f4d90395091a86ffe","old_content_sha256":"b675b19bce9c4d42d1d5fd05ea200bc9bfa07c6d872eec9f4d90395091a86ffe","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4811,4811],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4798:4798:41ab2c5b203e472dd94c1043720ab3fdfcfbf393f69eda81b04e085ea945d86d","old_content_sha256":"41ab2c5b203e472dd94c1043720ab3fdfcfbf393f69eda81b04e085ea945d86d","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4798,4798],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4805:4805:5278637891034cfb93bd18af15b628fa5b9b54044e9a525116b74ae0925669f7","old_content_sha256":"5278637891034cfb93bd18af15b628fa5b9b54044e9a525116b74ae0925669f7","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4805,4805],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4806:4806:51d522c2d8c003c44ac1c2e4ee44f18f58dcaac647f795b1e651c7dbadccf6ef","old_content_sha256":"51d522c2d8c003c44ac1c2e4ee44f18f58dcaac647f795b1e651c7dbadccf6ef","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4806,4806],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4812:4812:2607390d7c5e75337435625d7a09f06f290adca68fda0d00388f565fcf5ec796","old_content_sha256":"2607390d7c5e75337435625d7a09f06f290adca68fda0d00388f565fcf5ec796","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4812,4812],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4768:4768:81c2b5968f25c00afbe509385262e54a08800529d8b1fb6f064631fc94272bdd","old_content_sha256":"81c2b5968f25c00afbe509385262e54a08800529d8b1fb6f064631fc94272bdd","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4768,4768],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4808:4808:6401bd9799eab8cf204f5161c1cb88318a23c270a86555a6c4608d5a19b1ef80","old_content_sha256":"6401bd9799eab8cf204f5161c1cb88318a23c270a86555a6c4608d5a19b1ef80","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4808,4808],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4807:4807:2f5a1bda3505f1d44d89a2331d8fafe36afd0af103d059a1dd6bb84fdf233ffd","old_content_sha256":"2f5a1bda3505f1d44d89a2331d8fafe36afd0af103d059a1dd6bb84fdf233ffd","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4807,4807],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4769:4774:8abaa2682c4821406a20b13254f9b780536cec464136351e2c1260de4c0d5348","old_content_sha256":"8abaa2682c4821406a20b13254f9b780536cec464136351e2c1260de4c0d5348","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4769,4774],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4809:4809:81c2b5968f25c00afbe509385262e54a08800529d8b1fb6f064631fc94272bdd","old_content_sha256":"81c2b5968f25c00afbe509385262e54a08800529d8b1fb6f064631fc94272bdd","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4809,4809],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4791:4791:221c8d1dbc610f56be1fa3a3f5801c354a358345598ff44a56aaa931c572d760","old_content_sha256":"221c8d1dbc610f56be1fa3a3f5801c354a358345598ff44a56aaa931c572d760","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4791,4791],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4813:4815:82df18c41e6fea6406046140579ce91bbe1fb09edde1736a70efef833e283edb","old_content_sha256":"82df18c41e6fea6406046140579ce91bbe1fb09edde1736a70efef833e283edb","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4813,4815],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"guide_ingest_exact_project_prepared_grant_and_evidence","new_test_node":"tests/authorization/task_authority/test_shared_project_authority.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","old_assertion_id":"assertion:4745:4745:eb36804c11e73f11ca4639bdd1e71a92009cda9ecd96868d4c4ba20c38049ff6","old_content_sha256":"eb36804c11e73f11ca4639bdd1e71a92009cda9ecd96868d4c4ba20c38049ff6","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[4745,4745],"old_test_node":"tests/test_authorization.py::test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"lock_order":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"denial_side_effects":"preserved","replay":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"revocation":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."},"concealment":{"not_applicable_reason":"This relocated prepared-service test uses explicit repository/session doubles; it does not claim PostgreSQL locking, persistence, or lifecycle races."}},"target_layer":"service"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2168:2168:b2379bac2f65506364275da1eb902b0057f82f7f2fffbf45953bad9bdb36d3e0","old_content_sha256":"b2379bac2f65506364275da1eb902b0057f82f7f2fffbf45953bad9bdb36d3e0","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2168,2168],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2089:2089:8378c52c9bc363dce0ac05881d507bdb4d44e54d087f78ae6ae72d191f478f46","old_content_sha256":"8378c52c9bc363dce0ac05881d507bdb4d44e54d087f78ae6ae72d191f478f46","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2089,2089],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2181:2181:bb2e588f89ce2ff588226b9d8e59c93f34d52c760c23727435fcc9675df5441c","old_content_sha256":"bb2e588f89ce2ff588226b9d8e59c93f34d52c760c23727435fcc9675df5441c","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2181,2181],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2189:2189:85d731226c41299ed475b71eab61c1840de08c5d407faf688f5569ff6b40e527","old_content_sha256":"85d731226c41299ed475b71eab61c1840de08c5d407faf688f5569ff6b40e527","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2189,2189],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2174:2177:28d44e1e9dbdfea71f913633124975f4718e83ad20fb9437de0a2b060d56a723","old_content_sha256":"28d44e1e9dbdfea71f913633124975f4718e83ad20fb9437de0a2b060d56a723","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2174,2177],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2077:2077:a806035ea1d94c830bb8747448ca46da34238fb764bc24f08653043299335acc","old_content_sha256":"a806035ea1d94c830bb8747448ca46da34238fb764bc24f08653043299335acc","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2077,2077],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2169:2169:403d1645b4224756d262667ca0022e96d4ba1051a25d5577673e66880bb42f41","old_content_sha256":"403d1645b4224756d262667ca0022e96d4ba1051a25d5577673e66880bb42f41","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2169,2169],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"canonical_authority_denial_replaces_removed_active_contributor_exception","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2078:2078:74b5b4422fdb1662ed347a5890e471ce94f0540be270fefd8cd4cdd994673982","old_content_sha256":"74b5b4422fdb1662ed347a5890e471ce94f0540be270fefd8cd4cdd994673982","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2078,2078],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2080:2086:b2a465ec2b129a0900dee3166115d1701fed38d6825e49fb530e36c92e9613bf","old_content_sha256":"b2a465ec2b129a0900dee3166115d1701fed38d6825e49fb530e36c92e9613bf","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2080,2086],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2191:2191:88fab7833255048722c6f4f0fc4a3de4b6a2fdcbccde7daff525ae1982041cad","old_content_sha256":"88fab7833255048722c6f4f0fc4a3de4b6a2fdcbccde7daff525ae1982041cad","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2191,2191],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2079:2079:56e895e750b1c31afdec67a28e9ca9c20b24dbcd895f4dc2d73bc497845a1d80","old_content_sha256":"56e895e750b1c31afdec67a28e9ca9c20b24dbcd895f4dc2d73bc497845a1d80","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2079,2079],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2171:2171:fba377cc176a118636154033e7c6ad334081bcce9d2a2c369919c8dcf8d8f33a","old_content_sha256":"fba377cc176a118636154033e7c6ad334081bcce9d2a2c369919c8dcf8d8f33a","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2171,2171],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2162:2165:e3498fd024ffd07b54b4c7ef885ceb9fed7737e2277faa08c9949aefde95a458","old_content_sha256":"e3498fd024ffd07b54b4c7ef885ceb9fed7737e2277faa08c9949aefde95a458","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2162,2165],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2159:2159:371f12f548396f86275b97c2311419937f3d2ea820585a2270b938e7a853855e","old_content_sha256":"371f12f548396f86275b97c2311419937f3d2ea820585a2270b938e7a853855e","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2159,2159],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2173:2173:6bdbe41fbcd33de9baaf093cce79cf1aac4738ef6454a455db768d9399a5ad33","old_content_sha256":"6bdbe41fbcd33de9baaf093cce79cf1aac4738ef6454a455db768d9399a5ad33","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2173,2173],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2088:2088:84b3164c278d59de65f2780d9298d1e6bfe0dfd7cf5efe0d804a91d9a0e49a95","old_content_sha256":"84b3164c278d59de65f2780d9298d1e6bfe0dfd7cf5efe0d804a91d9a0e49a95","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2088,2088],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2172:2172:0f9e58680267a6ea38891b8f85a4cf5252655b569cce627138bb9f7c2fe5a328","old_content_sha256":"0f9e58680267a6ea38891b8f85a4cf5252655b569cce627138bb9f7c2fe5a328","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2172,2172],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2179:2179:d456fe12b0da7a32178ffe651a6b84225af7db818efedc6bb8919ce408721804","old_content_sha256":"d456fe12b0da7a32178ffe651a6b84225af7db818efedc6bb8919ce408721804","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2179,2179],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2010:2010:416bd33059eef67f678db5ab0616780b60f3b4631ee33e7859de150ea43f8cb5","old_content_sha256":"416bd33059eef67f678db5ab0616780b60f3b4631ee33e7859de150ea43f8cb5","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2010,2010],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"retired_packet_writer_choreography_not_equivalent_to_submission_authority_race","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2180:2180:0c19a0dea6017c4ebae604aca0fc2a0a09793cb9103b27837bd674606dd7a91c","old_content_sha256":"0c19a0dea6017c4ebae604aca0fc2a0a09793cb9103b27837bd674606dd7a91c","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2180,2180],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"lock_order":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"denial_side_effects":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"replay":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"revocation":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"evidence":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"transaction_ownership":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."},"concealment":{"not_applicable_reason":"The packet writer and its automatic intake/finalization/queue choreography are removed. This node proves canonical authority lifecycle serialization, not equivalent packet creation or dispatch; hidden TASK/ART composition owns separate creation/rollback proof."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2161:2161:24678b731432b1fd26ea0939a16b8aa5597f41e76011ff9b22e08eaf432167cd","old_content_sha256":"24678b731432b1fd26ea0939a16b8aa5597f41e76011ff9b22e08eaf432167cd","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2161,2161],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_lifecycle_change_before_contributor_operation_denies_without_effects","old_assertion_id":"assertion:2193:2193:353321ec08c9d18f555497315381d919fec515af0923ce7b8d18aaaef4dc96a7","old_content_sha256":"353321ec08c9d18f555497315381d919fec515af0923ce7b8d18aaaef4dc96a7","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2193,2193],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"canonical_contributor_lifecycle_task_write_serialization","new_test_node":"tests/authorization/task_authority/test_lifecycle_races.py::test_contributor_operation_commits_before_lifecycle_change","old_assertion_id":"assertion:2167:2167:7291592a5bbdcaeb1e4ea50ef38f804efae82499d65cf0902d43b44c51910179","old_content_sha256":"7291592a5bbdcaeb1e4ea50ef38f804efae82499d65cf0902d43b44c51910179","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[2167,2167],"old_test_node":"tests/test_tasks.py::test_contributor_task_writes_serialize_with_lifecycle_changes","security_dimensions":{"concurrency":"preserved","lock_order":"preserved","denial_side_effects":"preserved","replay":{"not_applicable_reason":"This is a two-session lifecycle/write race, not an idempotent operation replay proof."},"revocation":"preserved","evidence":"preserved","transaction_ownership":"preserved","concealment":{"not_applicable_reason":"This invokes owner operations and inspects stored outcomes; HTTP concealment has separate route tests."}},"target_layer":"integration"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:180:180:f8a01d58ad863c9d1bf0460518b0323789fbeb0b2c6ef792d684f28d1360ebd7","old_content_sha256":"f8a01d58ad863c9d1bf0460518b0323789fbeb0b2c6ef792d684f28d1360ebd7","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[180,180],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:188:188:d050f1eebfa8ef158e2d8f56a30dae769a50ee0279db2797b2399401da87c8fe","old_content_sha256":"d050f1eebfa8ef158e2d8f56a30dae769a50ee0279db2797b2399401da87c8fe","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[188,188],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:165:165:f43d4fadfec3dd1b124e7ea35e208c555ea270564da7c1906c1423edce3663c4","old_content_sha256":"f43d4fadfec3dd1b124e7ea35e208c555ea270564da7c1906c1423edce3663c4","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[165,165],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:134:134:05a2d0b8a41d5c8ce45e6a54750f89174479cc10a85b083e902929a6856b870f","old_content_sha256":"05a2d0b8a41d5c8ce45e6a54750f89174479cc10a85b083e902929a6856b870f","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[134,134],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:172:172:edfb771495e2ff075dedb5e5d9f116b22eac8b533c4f5d40283cce0362214ffe","old_content_sha256":"edfb771495e2ff075dedb5e5d9f116b22eac8b533c4f5d40283cce0362214ffe","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[172,172],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:163:163:42b0a4fb92d31c836cb4e473aba67baaf9c3496fa4930031e68d0f5c20279b48","old_content_sha256":"42b0a4fb92d31c836cb4e473aba67baaf9c3496fa4930031e68d0f5c20279b48","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[163,163],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:173:173:f8a01d58ad863c9d1bf0460518b0323789fbeb0b2c6ef792d684f28d1360ebd7","old_content_sha256":"f8a01d58ad863c9d1bf0460518b0323789fbeb0b2c6ef792d684f28d1360ebd7","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[173,173],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:133:133:cf2a865c9257bad94985c7a2f996d37a1eac06f494985791ee12edd1152fa372","old_content_sha256":"cf2a865c9257bad94985c7a2f996d37a1eac06f494985791ee12edd1152fa372","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[133,133],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:141:141:54ff0c1157e229212ff96bc064e93b309828bf86da78ea0b29aac790f3703564","old_content_sha256":"54ff0c1157e229212ff96bc064e93b309828bf86da78ea0b29aac790f3703564","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[141,141],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"}, + {"invariant_category":"action_aware_audit_input_mapping_and_availability","new_test_node":"tests/authorization/task_authority/test_audit_contract.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","old_assertion_id":"assertion:151:151:7b4f7119c60ee01948eb6e0925151db87c21bbc93d6abe5d22f9aa17f76907d4","old_content_sha256":"7b4f7119c60ee01948eb6e0925151db87c21bbc93d6abe5d22f9aa17f76907d4","old_revision":"471bbbb39f84d52529177a780d0b39b7e795cd71","old_source_span":[151,151],"old_test_node":"tests/test_audit.py::test_action_aware_audit_input_enforces_mapping_and_action_availability","security_dimensions":{"concurrency":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"lock_order":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"denial_side_effects":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"replay":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"revocation":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"evidence":"preserved","transaction_ownership":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."},"concealment":{"not_applicable_reason":"This relocated domain test validates exact action/permission and audit-input shapes, not persistence, transaction execution or concurrency."}},"target_layer":"domain"} + ] +} diff --git a/.ci/behavior-ownership/auth/authorization-audit-domain.json b/.ci/behavior-ownership/auth/authorization-audit-domain.json index aae95d650..fdda18d88 100644 --- a/.ci/behavior-ownership/auth/authorization-audit-domain.json +++ b/.ci/behavior-ownership/auth/authorization-audit-domain.json @@ -1,9 +1,13 @@ { "behavior_id": "auth.audit.domain", "group": "auth", - "reason": "Closed immutable audit resource registry contains no executable callables.", - "reviewed_by": ["WS-AUTH-001-12I required reviewers"], + "callables": [ + "app.modules.authorization.domain.audit.AuthorizationDecision.validate_outcome", + "app.modules.authorization.domain.audit.AuthorizationDenied.__init__", + "app.modules.authorization.domain.audit.AuthorizationDenied.public_code" + ], "schema": "workstream.behavior-ownership.v1", - "status": "structural_only", - "target": "backend/app/modules/authorization/domain/audit.py" + "status": "candidate", + "target": "backend/app/modules/authorization/domain/audit.py", + "unresolved_reason": "Decision validation and denial projection moved here unchanged from runtime.py; the former no-executable-code classification is no longer valid. Exact behavior-ownership adoption remains distinct from implementation regression review." } diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index ed52d6321..1a9271682 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -444,6 +444,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/resource_digest.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/task_authority.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/guide_compilation.py" @@ -548,6 +552,10 @@ "group": "auth", "target": "backend/app/modules/authorization/submission_preparation.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/task_authorization.py" + }, { "group": "artifacts", "target": "backend/app/modules/checkers/api/post_submit.py" @@ -932,6 +940,10 @@ "group": "lifecycle", "target": "backend/app/modules/reviews/schemas.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/tasks/api/authorization.py" + }, { "group": "lifecycle", "target": "backend/app/modules/tasks/api/submission_command.py" @@ -940,10 +952,18 @@ "group": "lifecycle", "target": "backend/app/modules/tasks/api/submission_context.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/tasks/api/transition_audit.py" + }, { "group": "lifecycle", "target": "backend/app/modules/tasks/authorization.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/tasks/authorized_commands.py" + }, { "group": "lifecycle", "target": "backend/app/modules/tasks/lifecycle.py" @@ -1093,7 +1113,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "ddf6c3e0221d3e8caddc9b7f556dd75616f5880272552976bbd7b04b167b72f9", + "authority_digest": "5fc2459ede25039ef5e37475ab34860e0f3d9fc5a68ead09ecdea8027317d4b2", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/module-boundaries/private-edge-debt.v1.json b/.ci/module-boundaries/private-edge-debt.v1.json index d891d4c8a..1e63e4dff 100644 --- a/.ci/module-boundaries/private-edge-debt.v1.json +++ b/.ci/module-boundaries/private-edge-debt.v1.json @@ -187,12 +187,6 @@ "imported_private_path": "app.modules.checkers.catalogue", "repair_owner": "WS-ARCH-001-04" }, - { - "source_file": "backend/app/modules/actors/service.py", - "target_module": "audit", - "imported_private_path": "app.modules.audit.repository", - "repair_owner": "WS-ARCH-001-07" - }, { "source_file": "backend/app/modules/actors/service.py", "target_module": "audit", @@ -205,12 +199,6 @@ "imported_private_path": "app.modules.audit.service", "repair_owner": "WS-ARCH-001-07" }, - { - "source_file": "backend/app/modules/actors/service.py", - "target_module": "tasks", - "imported_private_path": "app.modules.tasks.models", - "repair_owner": "WS-ARCH-001-03" - }, { "source_file": "backend/app/modules/artifacts/authorization.py", "target_module": "actors", @@ -505,30 +493,6 @@ "imported_private_path": "app.modules.audit.repository", "repair_owner": "WS-ARCH-001-07" }, - { - "source_file": "backend/app/modules/tasks/router.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.schemas", - "repair_owner": "WS-ARCH-001-03" - }, - { - "source_file": "backend/app/modules/tasks/router.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.service", - "repair_owner": "WS-ARCH-001-03" - }, - { - "source_file": "backend/app/modules/tasks/service.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.models", - "repair_owner": "WS-ARCH-001-03" - }, - { - "source_file": "backend/app/modules/tasks/service.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.service", - "repair_owner": "WS-ARCH-001-03" - }, { "source_file": "backend/app/modules/tasks/service.py", "target_module": "checkers", diff --git a/.commitrail/changes/task-project-grant-authorization.md b/.commitrail/changes/task-project-grant-authorization.md new file mode 100644 index 000000000..0da0873cb --- /dev/null +++ b/.commitrail/changes/task-project-grant-authorization.md @@ -0,0 +1,499 @@ +# Replace self-activated task eligibility with project authority + +- Initiative: None +- Durable disposition: Complete +- Intended merge outcome: Task claim/start and work-context use canonical authority; worker eligibility and old public packet submission are retired, while admission-backed submission remains hidden. + +## Intent + +An external identity or self-created eligibility row must not authorize work. +The user explicitly requested this repair before the broader TASK replacement. +An exact-project active Submitter grant authorizes contributor operations; +the canonical Operator permission alone authorizes a reasoned start override. + +## Baseline behavior being replaced + +This section records the pre-change behavior at `471bbbb3`, not the intended +merged API contract. + +`TaskService` reads `LegacyWorkflowEligibilityCompatibility` for claim, start, +submission and work-context actions. `/workers/me/profile` lets a token-role +worker activate global eligibility. Token roles also guard those methods. +The existing AUTH repository already owns canonical actor/link locking and +exact-project active-role lookup. AUTH's submission creation action already +has a separate exact hidden ART-backed resource contract; this repair must +not weaken it to admit an old JSON packet as an ART-backed Submission. + +## Bounded change + +### Allowed + +- `backend/app/modules/tasks/`: affected service/router/context construction, + public authorization facts/ports and preserved state/assignment guards. +- `backend/app/modules/authorization/` and delivery composition/dependencies: + exact task authority using existing actor/link, project-grant and Operator + permission owners, catalogue/resource declarations where necessary. +- `backend/app/modules/actors/`: remove eligibility activation, bridge, + exclusive schemas and repository accessors. Preserve stored rows/schema. +- Affected backend tests, fixtures and scripts; current AUTH/TASK plans, + specifications, capability ledger and boundary/debt inventories. +- Shared typed audit participant: exact TASK transition events and the existing + authorization-decision reference, without a parallel audit writer. +- Same-owner composition roots under `backend/app/adapters/{auth,tasks,audit}`: + wire the existing AUTH and audit implementations through TASK public ports. + No new private-import debt entry or validator exception is permitted. +- Catalogue parity migration only if required; no retained-data deletion. +- Coverage concurrency configuration and its existing contract tests: repair + SQLAlchemy greenlet line attribution discovered while verifying this change. + No workflow, test-selection, exclusion, dependency or floor weakening. + +### Not allowed + +- Guide-upload/setup changes, checker policy design, hidden API exposure, + task queue/invalidation worker implementation, contribution/review activation. +- Compatibility aliases, role claims as authority, default-allow adapters, + duplicate grant rules, weakened tests or coverage, history/data deletion. +- Merge, or changes to the paused API-drill branch. + +## Design and decisions + +The user selected retirement of old public submission, not public activation +of the hidden admission-backed command. The bounded manifest is: + +| Operation | Action | Permission and authority | +|---|---|---| +| Claim | `task.claim` | `task.claim`, exact-project Submitter | +| Assigned start | `task.start` | `task.claim`, exact-project Submitter | +| Reasoned start of another contributor's assignment | `operations.task.start_override` | Existing permission, system Operator | +| Contributor work context | `task.work_context.read` | `task.queue.read`, exact-project Submitter | +| Management work context | `project.task.work_context.read` | `project.task.manage`, covered Project Manager | + +Use new bounded action owner `task-project-grant-authorization`, not the +superseded broad AUTH-13 owner. Submission's existing action/resource and +hidden exposure boundary remain unchanged; its command reuses the existing +complete locked-policy validator before persistence or ART consumption. +The submission and worker-profile POST +routes are removed, not hidden or aliased. Contributor hints advertise only +claim/start where granted and valid; no submit/precheck hint remains. + +TASK public facts/port live in `tasks/api/authorization.py`; AUTH strict +resources/rules in `authorization/domain/task_authority.py`, the adapter in +`authorization/task_authorization.py`, and delivery wiring in the existing +`api/deps/authorization.py` with same-owner AUTH/TASK/audit composition roots. +Extend the existing kernel, prepared service, +catalogue, runtime resource union and audit registries; do not add an evaluator +outside AUTH. TASK command methods and response builders remain TASK-owned. +The database parity migration follows current `0016_guide_document_runtime`; +reconcile its revision number if guide work advances the migration head. + +Reuse AUTH decisions and typed public seams; TASK keeps lifecycle/assignment +ownership and passes server-loaded resource facts. Mutation authority must +remain current under the same transaction through writes, with a consistent +task/assignment/actor/link/grant lock order, aligned with hidden Submission +creation (which also locks its predecessor before AUTH). No repository query in TASK may become a +second authorization evaluator. Foreground revocation denial does not depend +on future asynchronous invalidation. Action hints use the same permission +mapping plus state and active-assignment ownership; hints are not authority. + +Remove affected token-role guards rather than adding project grants as another +requirement. Preserve canonical contributor checks, locked policy validation, +pre-submission rejection, exact assignment ownership, one-winner claims, +reasoned authorized overrides and audit attribution. Preserve retained +eligibility rows without a callable activation/read bridge. + +The broader ARCH-03B/03C work remains planned: reconcile its eligibility-removal +language to this delivered boundary without claiming queue, contribution-lock +or asynchronous invalidation completion. + +## Acceptance criteria + +- Exact active project Submitter permits claim/start where lifecycle, + assignment, lineage and intake permit; no worker token role is required. +- Missing, revoked, reviewer-only and foreign-project grants deny without + task/assignment/submission mutation. Suspended/deactivated actors and revoked + or foreign identity links deny, including stale request contexts. +- Start override requires canonical system Operator authority and a reason; + token admin/project-manager roles cannot bypass assignment ownership. +- Claim races have one winner; revocation races serialize without stale allow; + invalid intake cannot create a Submission or discard required audit evidence. +- Available actions reflect project permission, state and actual assignment. +- Old endpoint/schema/activation/bridge and exclusive consumers are absent; + retained data and required behavior tests remain protected. + +## Risk and review routing + +- Risk class: L1 +- Required reviewers: security, architecture/reuse, QA/test_delta, + product_ops, documentation and CI integrity. The lane catalogue replaces + the deleted eligibility-test entry with the new task-authority modules; + reviewers must verify that retained proof remains collected. +- Historical assertion-map treatment: 26 assertions targeting the four removed + activation tests retain their original revision, node, span and hash. Their + dispositions explicitly retire self-activation and point to the real HTTP + absence/no-registry-write proof. This is an intentional contract replacement, + not a claim of behavioral equivalence or preserved activation concurrency. + Unrelated identity assertions in the same historical map remain unchanged. + Four activation-provenance assertions also retain their historical references + but now explicitly identify the canonical admission privacy proof: the + accepted maximum-length identity remains private, and ActorProfileProvisioned + evidence does not copy external issuer, subject or token claims. This does + not preserve the removed activation event's external-identity payload. +- Human review focus: no second permission system or premature hidden action + activation; exact-project authority and assignment/lineage preservation. + +## Evidence + +Coverage inspection found cross-file line attribution in raw hosted lane data. +A bounded SQLAlchemy `greenlet_spawn`/`await_only` reproduction showed resumed +application lines attributed to the caller under default thread-only tracing. +The repository now declares thread and greenlet concurrency in its existing +coverage configuration, with an exact-line regression in +`backend/tests/test_coverage_contract.py`. Earlier percentages measured without +that setting cannot certify coverage; full hosted measurement must be repeated. +Test execution outcomes remain distinct from coverage measurement. No threshold +is reduced and no parallel coverage mechanism is introduced. + +The focused proof lives under `backend/tests/authorization/task_authority/`, +with retained TASK/checker/read tests and current intake/archive owners listed +below. It includes real PostgreSQL/API grant matrices, independent-session +races, rollback and exact policy rejection. Ruff, architectural boundaries, +lane/behavior catalogues, structural debt, stale wording and Markdown links +are deterministic checks. Hosted Backend owns full tests and unchanged +coverage floors. Current command results and review readiness belong in the PR, +not a second durable work queue. + +This cross-file retirement is one cohesive change: route and bridge removal, +canonical command wiring, and affected callers/tests must move together. +The large test delta is reviewed through the explicit replacement-owner map; +it does not authorize changing guide setup, review activation or CI thresholds. + +### Baseline consumer and proof map at `471bbbb3` + +- `tasks/service.py`: claim and submission revalidate a canonical human, but + start does not perform the same write revalidation; all three still use + token-role guards. Start reads task/assignment without a write lock. Replace + these guards and preserve the role-independent lifecycle controls. +- `TaskService._worker_lifecycle_context` currently checks `assigned_to`, not + the actual active assignment row. The replacement hint proof must exercise + inconsistent, absent and inactive assignment rows as well as grant denial. +- `actors/service.py`, `actors/repository.py`, `actors/schemas.py` and + `tasks/router.py` own the exclusive activation and bridge surface. The + `actors/models.py` mapping and database history must remain because deleting + retained eligibility rows is not authorized. +- `tests/test_tasks.py` includes old HTTP activation, disabled-eligibility and + service tests. Replace eligibility prerequisite/revocation assertions with + genuine project-grant controls rather than removing their behavior coverage. + `tests/actors/test_legacy_eligibility_postgresql.py` and the bridge allowlist + in `tests/test_auth.py` exclusively protect the removed implementation; + retain applicable identity/rate-limit checks from + `tests/actors/test_identity_bounds_and_rate_controls.py` on a surviving API. +- `scripts/api_contract_e2e.py` still invokes the removed worker activation + before separately granting the project role. Preserve the real role-grant + journey and remove the obsolete prerequisite. +- Add future focused tests under `tests/authorization/task_authority/` for + real request composition, exact grants, actor/link currentness, override + authority and concurrent revoke/write ordering. Existing TASK and hidden + admission-backed creation tests remain regression inputs, not substitutes + for the new integration proof. + +### Submission contract decision + +The current public `SubmissionCreate` accepts a package URI, caller hash and +manifest. `TaskService.create_submission` persists that packet directly. +Canonical `SubmissionCreationResourceContext` instead requires an admission +identifier and exact assignment/policy facts; +`TransactionalSubmissionCreationCommand` atomically consumes the ART admission. +These are not interchangeable resource facts. Do not fabricate an admission, +relax the canonical resource, or register an alternate packet-creation action. +The user approved removing the obsolete public submission route until the +canonical public cutover. This is an intentional public-surface removal; +the hidden admission-backed implementation and its regression tests remain. + +## Plan review findings + +Independent feasibility review confirmed the submission boundary decision: +neither a fabricated admission nor a parallel packet action is acceptable. +The submission choice and exact action manifest above resolve the planning +boundary. The implementation must satisfy these review findings: + +- New `task.claim` and `task.start` actions reuse `task.claim` permission; + normal start also requires the exact active own assignment. Existing + `operations.task.start_override` uses system Operator authority and reason. + Declare the activation owner and audit-registry database parity changes; + registering catalogue strings alone is insufficient. +- Work-context access must itself support canonical authority without token + worker roles. Specify its exact Submitter read action and preserve separate + management access; do not advertise removed submission/precheck operations. +- Lock task and assignment before canonical actor, exact link and grant; + retain locks through the caller-owned write transaction and consume exact + server-loaded resource facts through an AUTH-owned adapter at the delivery + root. TASK must not query grants or import AUTH implementation details. +- The review is code/contract feasibility inspection, not runtime or + implementation proof. + +### Dependency findings during fixture migration + +- `ARCH-LOCK-001`: the initial AUTH-first task approach inverted the existing + hidden command's TASK-first locks. Contributor work-context can overlap + submission creation on the same in-progress task. Align new commands with + TASK/assignment then AUTH, and prove independent-session serialization. + Revocation does not acquire TASK locks, so its target-grant lock still + serializes without adding a reverse TASK edge. +- `ARCH-LINEAGE-002`: hidden creation copies more policy columns than its + narrow context port validates. Invoke TASK's existing complete locked-context + loader before constructing a Submission; preserve malformed-body/crossed- + sidecar negatives without duplicating policy evaluation in AUTH. + +- Removing the old submission POST affects TASK, checker and review fixtures. + Do not simulate an HTTP success or call the removed packet writer from a + helper. TASK/ART tests need the real hidden admission-backed command; + checker/review tests may explicitly seed their upstream stored prerequisites + and exercise their own real owner operations. +- `tasks.schemas.SubmissionCreate` is not exclusive to the removed POST: + `checkers.schemas`, `checkers.service` and `checkers.runner` still consume + the packet as checker input. Preserve that shared schema until its real + checker consumers are replaced; it is not a callable submission-creation + path. The TASK router no longer accepts it for Submission creation. +- The ready-task resource guard is shared by claim and contributor context: + all assignment fields must be absent. A partial assignment must deny even + with an active project grant; exact prepared-resource tests cover each field. +- AUTH structural cleanup keeps one implementation: decision/outcome contracts + move from the runtime aggregate to its existing `domain.audit` owner; the + runtime aggregate exports those same definitions. Project-grant locking and + dispatch reuse `artifact_project_authority`, with distinct TASK and ART + resource guards. Exact audit target selection stays in `domain.audit_targets`. + No second authorization evaluator, event writer or compatibility variant is + introduced. Contributor lifecycle races move to the task-authority test + package and remain explicitly assigned to CI. + The behavior catalogue must also stop classifying `domain.audit` as + structural-only: it now owns the existing decision validator and denial + projection moved from `runtime`. Their record is an explicit candidate, + not a fabricated reviewed mutation-ownership claim. No previously reviewed + executable ownership is removed by this relocation. +- The hidden command does not perform the old packet route's automatic + finalize/enqueue choreography. It must not be represented as a replacement + public workflow. Preserve locked-policy rejection proof when migrating the + removed route's negative tests; copying policy columns is not validation. +- `LegacyActorIdentity` still has shared consumers through registered-actor + resolution in remaining task-management/checker/read routes. Deleting that + shared model without their cutover would break APIs. This repair removes the + eligibility bridge and affected contributor-command dependency, not retained + identity data or unrelated management authorization. +- Task transition evidence reuses `LifecycleAuditParticipant` and references + the canonical authorization decision. The decision's exact resource digest + binds task/assignment/locked-context/reason; the participant does not copy + token claims or fabricate an external actor identity. Its existing stored + audit-domain discriminator is not a new compatibility implementation. +- The typed TASK audit port is wired through the audit owner's composition + root. No business command imports another owner's audit implementation. + Six obsolete private edges are removed from the module ledger; none are + added. Operator reasons are retained as bounded task reasons, not token + identity claims. +- Do not translate every integrity error into an assignment conflict. Only + `uq_task_assignments_one_active_per_task` means a competing assignment; + unrelated storage failures must remain unavailable, with rollback. + +### Concrete focused verification + +Run through `backend/scripts/run_isolated_tests.py` against an owned PostgreSQL +server, using a fresh isolated database/role and the canonical Alembic head: + +```sh +python -m pytest -q tests/authorization/task_authority --override-ini addopts='' +python -m pytest -q tests/test_submission_composition.py --override-ini addopts='' +``` + +`test_prepared.py` is bounded kernel/handle proof; `test_postgresql.py` is real +signed project-role issuance plus prepared AUTH database proof; +`test_task_commands.py` executes actual task HTTP commands and observes rows. +These are not interchangeable evidence boundaries. Full affected regression +tests, races, deterministic checks and final reviews remain required. + +### Replacement test ownership + +- Identity-only row/read fixtures no longer create eligibility rows. One + shared actor fixture replaces the two overlapping profile helpers; grant + journeys use the real role-grant helper instead. Retained-table registration + tests still protect the stored data shape. +- Both canonical work-context routes preserve the structured locked-context + 422 response in OpenAPI through one shared response definition. Corruption + tests first prove authorized context access, then require exact policy + rejection, so an earlier missing-grant denial cannot mask their assertion. + Invalid-state contributor start is denied by AUTH's resource guard even with + a real project grant, with unchanged task/assignment state. Hidden submission + and precheck actions remain absent from contributor hints. +- Removed private contributor-wrapper mocks are replaced by the real API + grant/lifecycle denials and transactional rollback tests under + `tests/authorization/task_authority/`. Router failures are injected into the + current command owner across all five routes, preserving structured errors, + retryability and request correlation. Retained management-read/finalize + service tests remain separate. +- Eligibility-disable journeys now issue and revoke exact project grants via + the public authority APIs. Their denial assertions preserve task state, + assignment identity and the absence of unintended Submissions. Token worker + roles do not repair absent grants; role-free granted actors can claim. +- The old project-manager/token-based start-override test is replaced by + `test_manager_context_and_system_operator_override_are_distinct`: explicit + system Operator grant, required reason, manager denial, retained assignee and + exact decision/audit references. The removed context-bridge mock is replaced + by real contributor and manager work-context API checks; retained detail, + requirements and provenance-read owner checks remain. +- The public API script stops at claim/start while canonical submission + creation is hidden. It no longer uses JSON-packet POST success as evidence + of ART admission, submission finalization or checker routing. Its existing + revoke journey must explicitly issue a fresh grant before task work. +- `test_artifact_bindings_db.py` owns isolated ART transaction proof and already + doubles TASK/AUTH collaborators. Its new explicit TASK policy-validator + double preserves that boundary; real policy rejection remains owned by + `test_submission_policy.py`, not inferred from the isolated ART schema. +- `tests/submission_fixtures.py` seeds retained stored-packet prerequisites for + checker-owner tests, then invokes the existing TASK finalization and queue + operations. It returns a Submission ID, never a fabricated HTTP response. + Its ART lineage group remains entirely absent, as allowed for retained rows; + this is not proof of canonical submission creation, admission, or new writes. + TASK/ART creation tests must not use this fixture as their creation proof. +- Queue-recovery fixtures explicitly select the existing non-raising initial + dispatch mode only when testing persisted broker-failure recovery. They still + require the failed claim, exact failure code, audit attribution and idempotent + repair. The normal fixture retains raising behavior; no production dispatch + exception is swallowed or changed for test convenience. +- Mutated effective policy, compiled pre-submit bundle and crossed post-submit + sidecar tests move to `test_submission_policy.py` and invoke real hidden + TASK/AUTH composition. A nonexistent admission makes these negative-only + tests: each must fail with its exact locked-policy field before admission + lookup. They preserve no-submission/no-checker-write assertions and the + crossed-sidecar test preserves unchanged task locks and audit history. + They do not claim successful ART creation or public HTTP exposure. +- The removed packet POST's OpenAPI 422 schema test is retired with that route. + `test_public_surface.py` instead requires its absence and the retained GET; + locked-context error contracts on surviving task APIs remain tested. + Actual HTTP probes use a started task with a current assigned Submitter and + complete, forged-context and empty packets. They require 405, no POST in the + Allow header, unchanged task/assignment/submission/checker/audit snapshots, + and a usable empty retained GET response. +- Shared packet schema rejection moves to `tests/checkers/test_packet_schema.py`, + through its real `PreSubmitCheckRequest` consumer. Each forbidden top-level + field, nested injection and unsafe URI is checked separately with its exact + validation location and error type; a complete packet remains accepted. + These are schema proofs, not submission creation or authority evidence. +- Stored-version read/finalization regressions retain both packets and exercise + real GET, finalization and list operations, including foreign-contributor + concealment, unchanged prior hashes/finalization and stamped guide locks + after later guide activation. Their names explicitly identify retained-data + proof. Canonical creation/version allocation is separately owned by hidden + submission-composition and ART binding tests, not inferred from fixture rows. +- Lifecycle race ownership is explicit. Claim races execute and commit the + actual TASK command. Submission-authority races lock real TASK/assignment + context, invoke the existing AUTH preparation/consumption port, and commit + its exact allow evidence before the opposing lifecycle transition acquires + the actor/link locks. In the opposite ordering, current lifecycle state + denies and the work snapshot is unchanged. Suspension, deactivation and + identity-link revocation are covered in both orderings. + The submission-authority branch does not create a Submission, manufacture a + ready admission, execute intake, or dispatch a checker. Those old automatic + packet-writer assertions are explicitly retired, not called equivalent to + AUTH evidence. Successful TASK/ART composition and its rollback remain + separate tests with their stated collaborator boundaries. A full real + TASK/AUTH/ART successful-creation lifecycle race is not claimed here. +- After tracing every remaining consumer, remove the now-unused + `ActorService.require_active_human_write_actor` wrapper, its two exclusive + exceptions and its exclusive test-double selector. Canonical actor resolution + still rejects subject-kind drift; AUTH owns current actor/link/grant checks. + The retained ACTORS exact-row tests and new AUTH repository-selector tests + separately prove their real owners. Historical wrapper assertions identify + this intentional owner/contract replacement rather than claiming the old + issuer/subject selector or private exception API remains supported. +- Retire the removed packet POST's manager/other-worker status-code and + competing-POST tests. Its real absence/no-write probes replace public-route + expectations. Current hidden submission proof separately requires manager + and Operator grants not to substitute for Submitter authority, and rejects + a foreign contributor before TASK lookup. TASK's real PostgreSQL context + matrix/lock race and unique-version constraint tests remain; ART's isolated + PostgreSQL consumption/transaction tests own one-winner admission effects. + These boundaries are not represented as an exposed end-to-end submission API. +- Retire old packet-POST intake assertions with that endpoint, not the required + intake behaviors. `test_effective_intake_rules.py` executes current compiled + project rules over actual prepared ZIP bytes for complete content, missing + files, exact project evidence keys, project attestation terms and forbidden + files. The archive suite rejects duplicate physical members/collisions; + mandatory-checker and policy-lineage regressions remain in the default and + effective executor suites. The migrated policy-ID corruption test calls the + real hidden command and still requires a precise locked-context rejection. + The existing real PostgreSQL evidence-workflow test owns immutable blocked + evidence, bounded failure projection, absence of pass capability, and no + additional Submission/checker/review rows. Its fixture and collaborator + boundaries remain explicit; it is not a public submission drill. + +## Reconciliation + +- AUTH evidence-storage failures on both command and denial-restaging paths + return the structured retryable TASK 503 response. Service actors reach the + existing fixed-service matrix and canonical denial staging, rather than being + rejected by an unaudited adapter type check. Actor-ID substitution remains + rejected; no service TASK action is enabled. The full fixed-service/operation + negative matrix checks exact denial evidence and rollback restaging. +- Reconcile retained test expectations with the activated TASK action set and + the discovered current migration head. Keep exact action-set comparison and + failed-downgrade rollback assertions. Replace a stale prose census assertion + with the documented non-activation invariant; independent ART custody and + permission mappings remain fully checked. + +- Reset-schema fingerprint uses the same PostgreSQL 16 engine as Backend CI. + Fresh 16/17 schema captures each contained 4,806 rows; only three namespace + function identities differed (`pg_catalog.json` versus `json`). All remaining + captured objects matched. Keep a single CI-engine fingerprint, not a relaxed + multi-hash guard or a normalization that could conceal object changes. + +- Migration preflight recognizes both the preceding guide-document revision and + this change's head. Repeated `upgrade head` must preserve an already-current + database. Register the five new Python owners in the existing behavior + partition with exact-path additive approval and neighbor-rejection proof; + preserve existing assignments, protected-base custody and fail-closed checks. + Partition completeness must be checked after new source files are tracked, + because its inventory deliberately uses Git-tracked targets. + +- Review repairs: preserve the complete Alembic chain in the graph assertion, + including `0016_guide_document_runtime`; remove outdated rollout/eligibility + claims from the AUTH runbook; distinguish target contribution-policy locks, + submission handoff and canonical recovery from retained runtime routes in the + operating manual. No pending feature is activated by these documentation fixes. +- Review identified pre-existing database-level Submission ownership debt: + assignment and predecessor references have individual rather than composite + ownership constraints. This change does not alter those constraints or expose + a new writer. Existing TASK/ART command validation enforces exact ownership; + database-enforced lineage against privileged direct SQL remains a separate + hardening concern, not a claimed guarantee of this change. + +Retained-packet integration tests exercise actual checker routing, retry, +revision-version visibility, audit redaction and locked lineage without calling +the removed public writer. One audit-read test explicitly seeds historical +`submission_created` evidence; it does not claim that hidden creation emits +that event. The revision test no longer claims the removed writer's +`needs_revision -> submitted` audit transition; its real evaluation transitions, +version links, immutable prior packet, privacy and foreign-contributor denials +remain asserted. Trial intake failures are covered by the current actual-ZIP +intake and durable blocked-evidence owners identified above, not fabricated +HTTP creation responses. + +- Based on main after PR #393. The guide-upload work owns PROJECTS guide + schemas/routes and ART guide ingestion; this repair does not edit those. + Shared AUTH/composition/tests/docs must be reconciled against its actual + branch diff before push. +- The guide work also removed `/auth/me` in its separate branch. Overlapping + files include `tests/test_tasks.py`, `tests/test_api_controls.py`, `tests/test_auth.py`, + `scripts/api_contract_e2e.py` and current authorization/roadmap docs. Preserve + that actor-self correction when reconciling; do not restore its old callers. +- Both branches currently add a migration after `0016_guide_document_runtime`: + guide-upload owns `0017_guide_document_creation`, this change owns + `0017_task_project_authority`. Do not merge both as independent Alembic heads. + Whichever change merges second must rebase its migration onto the then-current + head and rerun clean-schema/migration proof. Neither branch is implicit + implementation authority for the other; reconcile only against merged main. +- Paused API drill remains on its original branch; update affected drill + callers only where they invoke the removed eligibility workflow. +- Remaining boundaries: canonical public submission, the remaining management + and read-route cutovers, complete ContributionPolicy lineage and durable + assignment invalidation remain separate work. This change does not certify + those capabilities or the complete public API drill. Hosted full regression + and coverage evidence, plus focused internal review, are required before a + merge-readiness claim. diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md index bac554780..d56deaa2c 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md @@ -4,6 +4,13 @@ Status: non-executable planning skeleton after 03A. Risk: L1. Outcome: TASKS exp assignment, contributor, predecessor and immutable locked-context commands and facts without importing PROJECTS or AUTH internals. +The bounded [project-grant repair](../../../../changes/task-project-grant-authorization.md) +already owns canonical contributor claim/start/work-context, separate management +work-context and system-Operator start authority. Reuse its command/port and +assignment transaction, not a second claim implementation. This skeleton still +owns the missing contribution-policy attempt locks, queues, broader projections +and invalidation behavior below; it is not completed by that repair. + The TASK readiness command inherits the ContributionPolicyVersion already bound to the active Project Guide and locks it once as `WorkstreamTask.locked_contribution_policy_version_id` before the task becomes diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03C-auth-task-readiness.md b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03C-auth-task-readiness.md index f9e01ff78..e2a292619 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03C-auth-task-readiness.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03C-auth-task-readiness.md @@ -14,9 +14,13 @@ role-only fallback or public Submission cutover. ## Proposed exact surface/action manifest -These are proposed registrations, not claims that these ActionIds exist now. -Only `operations.task.start_override` already has an ActionId; the other -named task permissions exist but their actions must be added explicitly. +The bounded [project-grant repair](../../../../changes/task-project-grant-authorization.md) +owns the active `task.claim`, `task.start`, `task.work_context.read`, +`project.task.work_context.read` and `operations.task.start_override` actions. +Reuse those registrations and extend their exact locked-context proof when +03B delivers contribution-policy attempt lineage. The other rows below remain +proposed registrations, not claims of usable actions. This repair does not +activate the queue, remaining projections or invalidation handler. | Surface | Proposed action | Permission / principal | |---|---|---| @@ -43,7 +47,7 @@ named task permissions exist but their actions must be added explicitly. Normal start reuses the existing submitter claim entitlement, with a separate action and stricter active-assignment guard; this proposes no separately grantable start permission. Security/product review must check that mapping -against the canonical role matrix before this design is locked. Give the three +against the canonical role matrix when extending the attempt lineage. Give the three locked-context projections separate declared surfaces under project, operations and audit routing, with permission-appropriate fields; do not make one action switch permission according to a token role. diff --git a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md index 7ff2cc814..d199e335f 100644 --- a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md +++ b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md @@ -29,10 +29,13 @@ compilation/component/approval identities, session and transaction. Test cross-project, stale generation, substituted action/resource, revoked authority, concurrent replay and rollback before activating the live adapter. -No legacy task-eligibility fallback survives in a replacement command. Remove -its affected consumer with ARCH-03B/03C; later cleanup proves absent consumers, -not permission to delete a still-live public path. Existing actions and -registrations are reused, never re-created under aliases. +The bounded [task project-grant repair](../../../changes/task-project-grant-authorization.md) +replaces contributor claim/start/work-context authority, adds separate management +context and reasoned system-Operator start, and removes self-activated eligibility +and public JSON-packet Submission creation. Admission-backed creation stays hidden. +ARCH-03B/03C must reuse these exact actions and command owners while completing +ContributionPolicyVersion lineage, ready queues, remaining projections and durable +invalidation. Do not restore eligibility or register replacement aliases. ## WS-AUTH-001-OUTBOX-01 — unavailable dispatcher contract diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 3eed85115..76d45a40f 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -29,7 +29,8 @@ _REQUEST_ORIGIN_HEAD_REVISION = "0013_compilation_request_origin" _PROJECT_ROLE_HEAD_REVISION = "0014_project_role_scope" _RUNTIME_CONFIGURATION_HEAD_REVISION = "0015_guide_runtime_configuration" -_CURRENT_HEAD_REVISION = "0016_guide_document_runtime" +_GUIDE_DOCUMENT_HEAD_REVISION = "0016_guide_document_runtime" +_CURRENT_HEAD_REVISION = "0017_task_project_authority" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_v01_baseline migration" @@ -71,6 +72,7 @@ def do_run_migrations(connection: Connection) -> None: (_REQUEST_ORIGIN_HEAD_REVISION,), (_PROJECT_ROLE_HEAD_REVISION,), (_RUNTIME_CONFIGURATION_HEAD_REVISION,), + (_GUIDE_DOCUMENT_HEAD_REVISION,), (_CURRENT_HEAD_REVISION,), ): raise RuntimeError(_RECREATE_GUIDANCE) diff --git a/backend/alembic/versions/0017_task_project_authority.py b/backend/alembic/versions/0017_task_project_authority.py new file mode 100644 index 000000000..497a09cfd --- /dev/null +++ b/backend/alembic/versions/0017_task_project_authority.py @@ -0,0 +1,57 @@ +"""Register exact task authority evidence without altering retained data.""" + +from alembic import op +import sqlalchemy as sa + +revision = "0017_task_project_authority" +down_revision = "0016_guide_document_runtime" +branch_labels = None +depends_on = None + +_CONSTRAINT = "ck_audit_events_authorization_action_evidence" +_PAIRS = ( + ("task.claim", "task.claim"), + ("task.start", "task.claim"), + ("task.work_context.read", "task.queue.read"), + ("project.task.work_context.read", "project.task.manage"), +) + + +def _pair(action: str, permission: str) -> str: + return ( + f"(((action_id)::text = '{action}'::text) " + f"AND ((permission_id)::text = '{permission}'::text))" + ) + + +def _amend(add: bool) -> None: + definition = op.get_bind().execute(sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass and conname=:name" + ), {"name": _CONSTRAINT}).scalar_one() + anchor = _pair("operations.task.start_override", "operations.task.start_override") + # The canonical constraint repeats exact pairs in the action registry and + # in its permission-binding branch. Both must admit the same new pairs. + if definition.count(anchor) != 2: + raise RuntimeError("task authority audit anchor changed") + extension = "".join(" OR " + _pair(action, permission) for action, permission in _PAIRS) + if add: + if any(_pair(*pair) in definition for pair in _PAIRS): + raise RuntimeError("task authority actions already registered") + definition = definition.replace(anchor, anchor + extension) + else: + if definition.count(extension) != 2: + raise RuntimeError("task authority audit extension changed") + definition = definition.replace(extension, "") + op.execute(f"alter table audit_events drop constraint {_CONSTRAINT}") + op.execute(f"alter table audit_events add constraint {_CONSTRAINT} " + definition) + + +def upgrade() -> None: + _amend(True) + + +def downgrade() -> None: + # Constraint validation intentionally refuses to erase evidence for these + # actions. No downgrade deletes retained audit or eligibility rows. + _amend(False) diff --git a/backend/app/adapters/audit/__init__.py b/backend/app/adapters/audit/__init__.py new file mode 100644 index 000000000..45f0f5823 --- /dev/null +++ b/backend/app/adapters/audit/__init__.py @@ -0,0 +1,41 @@ +"""Shared audit owner composition for typed product transition ports.""" + +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.audit.schemas import ( + LifecycleAuditEntityType, LifecycleAuditEventInput, LifecycleAuditEventType, + LifecycleAuditReason, LifecycleAuditReferenceKind, +) +from app.modules.audit.service import LifecycleAuditParticipant +from app.modules.tasks.api import TaskAuthorityOperation, TaskTransitionAuditPort, TaskTransitionFacts + + +class _TaskTransitionAudit: + def __init__(self, session: AsyncSession) -> None: + self._participant = LifecycleAuditParticipant(session) + + async def record(self, facts: TaskTransitionFacts) -> None: + event_type = { + TaskAuthorityOperation.CLAIM: LifecycleAuditEventType.TASK_CLAIMED, + TaskAuthorityOperation.START: LifecycleAuditEventType.TASK_STARTED, + TaskAuthorityOperation.START_OVERRIDE: LifecycleAuditEventType.TASK_START_OVERRIDDEN, + }[facts.operation] + await self._participant.add_event(LifecycleAuditEventInput( + event_id=uuid4(), entity_type=LifecycleAuditEntityType.TASK, entity_id=facts.task_id, + event_type=event_type, from_status=facts.from_status, to_status=facts.to_status, + actor_id=facts.actor_profile_id, reason=LifecycleAuditReason.STATE_CHANGED, + task_reason=facts.reason if facts.reason and facts.reason.strip() else None, + references={ + LifecycleAuditReferenceKind.PROJECT: facts.project_id, + LifecycleAuditReferenceKind.TASK: facts.task_id, + LifecycleAuditReferenceKind.ASSIGNMENT: facts.assignment_id, + LifecycleAuditReferenceKind.AUTHORIZATION_DECISION: facts.authorization_decision_id, + }, + )) + + +def task_transition_audit(session: AsyncSession) -> TaskTransitionAuditPort: + """Use the existing participant in the same caller-owned transaction.""" + return _TaskTransitionAudit(session) diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index fa3bd16da..4d7a08c18 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -24,6 +24,7 @@ from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import AuthorizationContext +from app.modules.authorization.task_authorization import PreparedTaskAuthorization from app.modules.authorization.guide_compilation_projections import ( ArtifactPolicyProjectionAuthorization, GuideSufficiencyProjectionAuthorization, @@ -35,6 +36,13 @@ def setup_finalization_authorization(session: AsyncSession) -> SetupFinalization return SetupFinalizationAuthorization(session) +def task_authorization( + session: AsyncSession, context: AuthorizationContext, +) -> PreparedTaskAuthorization: + """Compose exact task authority in AUTH's registered composition root.""" + return PreparedTaskAuthorization(session, context) + + def guide_sufficiency_projection_authorization( session: AsyncSession, ) -> GuideSufficiencyProjectionAuthorization: @@ -73,6 +81,7 @@ def contribution_policy_authorization( __all__ = ( + "task_authorization", "guide_compilation_request_authority", "guide_compilation_execution_authority", "ContributionPolicyAuthorization", diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index 879f39930..981e4b1ef 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -1,6 +1,7 @@ """TASK-owned composition adapters and transaction roots.""" from sqlalchemy.ext.asyncio import AsyncSession +from uuid import UUID from app.modules.artifacts.api import ( SubmissionAdmissionConsumptionPort, @@ -18,15 +19,28 @@ TaskSubmissionContextPort, ) from app.modules.tasks.repository import TaskRepository +from app.modules.tasks.authorized_commands import AuthorizedTaskCommands +from app.modules.tasks.api import TaskAuthorizationPort, TaskTransitionAuditPort from app.modules.tasks.submission_composition import TaskSubmissionCreationService __all__ = ( + "task_commands", "DenySubmissionCreationAuthorization", "TransactionalSubmissionCreationCommand", "task_submission_context_port", ) +def task_commands( + session: AsyncSession, *, authorization: TaskAuthorizationPort, + audit: TaskTransitionAuditPort, actor_profile_id: UUID, +) -> AuthorizedTaskCommands: + """Compose TASK commands without exposing private product imports to delivery.""" + return AuthorizedTaskCommands( + session, authorization=authorization, audit=audit, actor_profile_id=actor_profile_id, + ) + + def task_submission_context_port(session: AsyncSession) -> TaskSubmissionContextPort: """Bind the public TASK submission-context port to its repository.""" return TaskRepository(session) diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index e3d5689e2..6d940be53 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -111,6 +111,63 @@ async def get_authorization_actor( return await resolve_authorization_actor(request, result, session, rate_control) +async def get_task_commands( + request: Request, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> AsyncIterator[object]: + from app.adapters.audit import task_transition_audit + from app.adapters.auth import task_authorization + from app.adapters.tasks import task_commands + from app.modules.tasks.api import TaskAuthorityDenied + + request_id, correlation_id = (UUID(value) for value in request_ids(request)) + context = _authorization_context(resolved, request_id, correlation_id) + # Identity provisioning is committed by its owner. Discard its read-only + # refresh transaction before TASK takes the sole command transaction. + await session.rollback() + authority = task_authorization(session, context) + try: + yield task_commands( + session, + authorization=authority, + audit=task_transition_audit(session), + actor_profile_id=context.actor_profile_id, + ) + except TaskAuthorityDenied as exc: + await session.rollback() + try: + if await authority.restage_denial(exc): + await session.commit() + except (AuthorizationEvidenceUnavailable, SQLAlchemyError) as evidence_error: + await session.rollback() + raise StructuredHTTPException( + status_code=503, + detail="Task authority unavailable", + error_code="task_authority_unavailable", + error_message="Task authority unavailable", + retryable=True, + ) from evidence_error + raise StructuredHTTPException( + status_code=403, + detail="Task authority denied", + error_code="permission_not_granted", + error_message="Task authority denied", + ) from exc + except (AuthorizationEvidenceUnavailable, SQLAlchemyError) as exc: + await session.rollback() + raise StructuredHTTPException( + status_code=503, + detail="Task authority unavailable", + error_code="task_authority_unavailable", + error_message="Task authority unavailable", + retryable=True, + ) from exc + finally: + if session.in_transaction(): + await session.rollback() + + async def get_authorization_actor_identity( resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], ) -> ActorIdentityFacts: diff --git a/backend/app/modules/actors/repository.py b/backend/app/modules/actors/repository.py index 95cb96395..a0e8496df 100644 --- a/backend/app/modules/actors/repository.py +++ b/backend/app/modules/actors/repository.py @@ -14,7 +14,6 @@ ActorIdentityLink, ActorProfile, LegacyActorIdentity, - LegacyWorkflowEligibility, ) @@ -255,52 +254,3 @@ async def upsert_legacy_identity( if persisted is None: raise RuntimeError("legacy identity upsert did not return a row") return persisted - - async def get_legacy_eligibility( - self, - actor_id: str, - profile_type: str, - scope_type: str, - scope_id: str, - ) -> LegacyWorkflowEligibility | None: - """Load one classified legacy workflow-eligibility row.""" - return await self._session.scalar( - select(LegacyWorkflowEligibility) - .where( - LegacyWorkflowEligibility.actor_id == actor_id, - LegacyWorkflowEligibility.profile_type == profile_type, - LegacyWorkflowEligibility.scope_type == scope_type, - LegacyWorkflowEligibility.scope_id == scope_id, - ) - .execution_options(populate_existing=True) - ) - - async def insert_legacy_eligibility_if_absent( - self, - eligibility: LegacyWorkflowEligibility, - ) -> bool: - """Insert compatibility metadata without overwriting established state.""" - result = await self._session.execute( - insert(LegacyWorkflowEligibility) - .values( - id=eligibility.id, - actor_id=eligibility.actor_id, - profile_type=eligibility.profile_type, - status=eligibility.status, - skill_tags=eligibility.skill_tags, - scope_type=eligibility.scope_type, - scope_id=eligibility.scope_id, - profile_metadata=eligibility.profile_metadata, - ) - .on_conflict_do_nothing( - index_elements=[ - LegacyWorkflowEligibility.actor_id, - LegacyWorkflowEligibility.profile_type, - LegacyWorkflowEligibility.scope_type, - LegacyWorkflowEligibility.scope_id, - ] - ) - .returning(LegacyWorkflowEligibility.id) - ) - await self._session.flush() - return result.scalar_one_or_none() is not None diff --git a/backend/app/modules/actors/schemas.py b/backend/app/modules/actors/schemas.py index 8517e0007..f5235100b 100644 --- a/backend/app/modules/actors/schemas.py +++ b/backend/app/modules/actors/schemas.py @@ -12,20 +12,6 @@ from app.modules.authorization.catalogue import ActionId -def normalize_skill_tags(value: list[str]) -> list[str]: - """Return stable deduplicated legacy workflow skill tags.""" - normalized_tags: list[str] = [] - seen_tags: set[str] = set() - for raw_tag in value: - tag = raw_tag.strip().lower() - if not tag or len(tag) > 64: - raise ValueError("invalid skill tag") - if tag not in seen_tags: - normalized_tags.append(tag) - seen_tags.add(tag) - return normalized_tags - - class ActorProfileUpdateRequest(BaseModel): """Human-owned display fields accepted by the canonical self API.""" @@ -126,35 +112,3 @@ class ActorIdentityLinkAdminResponse(BaseModel): last_verified_at: datetime | None revoked_at: datetime | None reactivated_at: datetime | None - - -class LegacyWorkflowEligibilityActivationRequest(BaseModel): - """Temporary non-authoritative intake metadata for existing task workflows.""" - - model_config = ConfigDict(extra="forbid") - - skill_tags: list[str] = Field(default_factory=list, max_length=100) - - @field_validator("skill_tags") - @classmethod - def validate_skill_tags(cls, value: list[str]) -> list[str]: - return normalize_skill_tags(value) - - -class LegacyWorkflowEligibilityResponse(BaseModel): - """Temporary compatibility response that grants no product permission.""" - - model_config = ConfigDict(extra="forbid", from_attributes=True) - - id: str - actor_id: str - profile_type: str - status: str - skill_tags: list[str] - scope_type: str - scope_id: str - profile_metadata: dict - external_subject: str - external_issuer: str - created_at: datetime - updated_at: datetime diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 5b58ac9be..d76449750 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -1,4 +1,4 @@ -"""Canonical actor resolution and bounded legacy workflow compatibility.""" +"""Canonical actor resolution and identity lifecycle checks.""" from __future__ import annotations @@ -8,14 +8,10 @@ from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession -from app.core.permissions import require_any_role from app.modules.actors.models import ( - GLOBAL_PROFILE_SCOPE_ID, - GLOBAL_PROFILE_SCOPE_TYPE, ActorIdentityLink, ActorProfile, LegacyActorIdentity, - LegacyWorkflowEligibility, ) from app.modules.actors.repository import ActorRepository from app.modules.actors.schemas import ( @@ -23,11 +19,8 @@ ActorProfileAdminResponse, ActorProfileSelfResponse, ActorProfileUpdateRequest, - LegacyWorkflowEligibilityActivationRequest, - LegacyWorkflowEligibilityResponse, ) from app.modules.actors.service_identities import ServiceIdentity -from app.modules.audit.repository import AuditRepository from app.modules.audit.schemas import ( ActorReferenceKind, AuthorityAuditEventInput, @@ -35,7 +28,6 @@ ) from app.modules.audit.service import AuditService from app.modules.authorization.runtime import ActorSelfResourceContext -from app.modules.tasks.models import AuditEvent from app.schemas.auth import ActorContext, VerifiedIssuerToken, actor_id_from_external_identity @@ -71,24 +63,6 @@ class ActorDeactivated(ActorRegistryError): code = "actor_deactivated" -class ActorProfileDisabled(ActorRegistryError): - """Temporary compatibility denial for a disabled eligibility row.""" - - status_code = 403 - code = "legacy_workflow_eligibility_disabled" - - -class ActiveHumanWriteActorRequired(ActorRegistryError): - """The exact canonical caller is not currently eligible to write.""" - - status_code = 403 - code = "active_contributor_required" - - -class CanonicalWriteActorUnavailable(RuntimeError): - """Canonical profile/link state is missing or internally inconsistent.""" - - @dataclass(frozen=True) class ResolvedActor: """Canonical profile and exact verified identity link for one request.""" @@ -117,7 +91,6 @@ def __init__(self, session: AsyncSession) -> None: self._session = session self._repo = ActorRepository(session) self._audit = AuditService(session) - self._legacy_audit = AuditRepository(session) async def lock_admission_proof( self, @@ -293,26 +266,6 @@ async def lock_actor_self_for_authorization( """Lock the exact profile then its link and reject identity drift.""" return await self.lock_actor_for_authorization(resolved) - async def require_active_human_write_actor(self, actor: ActorContext) -> None: - """Lock and revalidate one exact human caller in the current transaction.""" - profile = await self._repo.get_actor_profile(actor.actor_id, for_update=True) - if profile is None: - raise CanonicalWriteActorUnavailable("canonical actor profile is missing") - if profile.actor_kind != "human" or profile.status != "active": - raise ActiveHumanWriteActorRequired("active contributor identity required") - - link = await self._repo.get_identity_link( - actor.external_issuer, - actor.external_subject, - for_update=True, - ) - if link is None: - raise CanonicalWriteActorUnavailable("canonical identity link is missing") - if link.actor_profile_id != profile.id or link.subject_kind != "human": - raise CanonicalWriteActorUnavailable("canonical identity link is inconsistent") - if link.status != "active": - raise ActiveHumanWriteActorRequired("active contributor identity required") - async def update_self( self, resolved: ResolvedActor, @@ -399,84 +352,6 @@ async def refresh_legacy_identity(self, actor: ActorContext) -> LegacyActorIdent await self._session.commit() return identity - async def activate_legacy_workflow_eligibility( - self, - actor: ActorContext, - payload: LegacyWorkflowEligibilityActivationRequest, - ) -> LegacyWorkflowEligibilityResponse: - """Activate temporary submitter intake metadata without creating authority.""" - require_any_role(actor, {"worker"}) - await self._repo.lock_external_identity( - actor.external_issuer, - actor.external_subject, - ) - identity = await self._repo.upsert_legacy_identity(self._legacy_identity_from_actor(actor)) - eligibility = await self._repo.get_legacy_eligibility( - actor.actor_id, - "worker", - GLOBAL_PROFILE_SCOPE_TYPE, - GLOBAL_PROFILE_SCOPE_ID, - ) - previous_status = None - previous_tags: list[str] = [] - inserted = False - if eligibility is None: - eligibility = LegacyWorkflowEligibility( - id=str(uuid4()), - actor_id=actor.actor_id, - profile_type="worker", - status="active", - skill_tags=payload.skill_tags, - scope_type=GLOBAL_PROFILE_SCOPE_TYPE, - scope_id=GLOBAL_PROFILE_SCOPE_ID, - profile_metadata={"source": "legacy_worker_profile_api"}, - ) - inserted = await self._repo.insert_legacy_eligibility_if_absent(eligibility) - eligibility = await self._repo.get_legacy_eligibility( - actor.actor_id, - "worker", - GLOBAL_PROFILE_SCOPE_TYPE, - GLOBAL_PROFILE_SCOPE_ID, - ) - if eligibility is None: - raise RuntimeError("legacy eligibility insert did not return a row") - else: - if eligibility.status == "disabled": - raise ActorProfileDisabled("Legacy workflow eligibility is disabled") - previous_status = eligibility.status - previous_tags = list(eligibility.skill_tags) - eligibility.status = "active" - eligibility.skill_tags = payload.skill_tags - eligibility.profile_metadata = {"source": "legacy_worker_profile_api"} - eligibility.updated_at = func.now() - - if ( - inserted - or previous_status != eligibility.status - or previous_tags != eligibility.skill_tags - ): - await self._write_legacy_eligibility_audit( - actor, - eligibility, - from_status=previous_status, - ) - await self._session.commit() - await self._session.refresh(eligibility) - return LegacyWorkflowEligibilityResponse( - id=eligibility.id, - actor_id=eligibility.actor_id, - profile_type=eligibility.profile_type, - status=eligibility.status, - skill_tags=list(eligibility.skill_tags), - scope_type=eligibility.scope_type, - scope_id=eligibility.scope_id, - profile_metadata=dict(eligibility.profile_metadata), - external_subject=identity.external_subject, - external_issuer=identity.external_issuer, - created_at=eligibility.created_at, - updated_at=eligibility.updated_at, - ) - @staticmethod def self_response( profile: ActorProfile, @@ -593,53 +468,3 @@ async def _write_provisioning_events( **common, ) ) - - async def _write_legacy_eligibility_audit( - self, - actor: ActorContext, - eligibility: LegacyWorkflowEligibility, - *, - from_status: str | None, - ) -> None: - audit = actor.audit_context() - await self._legacy_audit.add_audit_event( - AuditEvent( - id=str(uuid4()), - entity_type="legacy_workflow_eligibility", - entity_id=eligibility.id, - event_type="legacy_workflow_eligibility_activated", - from_status=from_status, - to_status=eligibility.status, - actor_id=audit.actor_id, - external_subject=audit.external_subject, - external_issuer=audit.external_issuer, - actor_roles=list(audit.actor_roles), - claim_snapshot=audit.claim_snapshot, - auth_source=audit.auth_source, - is_dev_auth=audit.is_dev_auth, - reason="legacy_intake_compatibility", - event_payload={"skill_tags": list(eligibility.skill_tags)}, - ) - ) - - -class LegacyWorkflowEligibilityCompatibility: - """Enumerated read-only bridge for task eligibility during staged cutover.""" - - def __init__(self, session: AsyncSession) -> None: - self._repository = ActorRepository(session) - - async def get_active_submitter_eligibility( - self, - actor_profile_id: str, - ) -> LegacyWorkflowEligibility | None: - """Return active legacy submitter metadata without granting permission.""" - eligibility = await self._repository.get_legacy_eligibility( - actor_profile_id, - "worker", - GLOBAL_PROFILE_SCOPE_TYPE, - GLOBAL_PROFILE_SCOPE_ID, - ) - if eligibility is None or eligibility.status != "active": - return None - return eligibility diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 071fb1de3..3ce5162a3 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -78,6 +78,7 @@ class LifecycleAuditEntityType(StrEnum): """Closed product-fact namespaces admitted by the shared participant.""" + TASK = "task" REVIEW_QUEUE_ENTRY = "review_queue_entry" REVIEW_LEASE = "review_lease" REVIEW = "review" @@ -91,6 +92,9 @@ class LifecycleAuditEntityType(StrEnum): class LifecycleAuditEventType(StrEnum): """Canonical REV/CON lifecycle facts admitted by the shared participant.""" + TASK_CLAIMED = "TaskClaimed" + TASK_STARTED = "TaskStarted" + TASK_START_OVERRIDDEN = "TaskStartOverridden" REVIEW_QUEUE_ENTRY_CREATED = "ReviewQueueEntryCreated" REVIEW_ROUTED_TO_PREFERRED_REVIEWER = "ReviewRoutedToPreferredReviewer" REVIEWER_PREFERENCE_EXPIRED = "ReviewerPreferenceExpired" @@ -132,6 +136,7 @@ class LifecycleAuditReason(StrEnum): class LifecycleAuditReferenceKind(StrEnum): """Closed UUID reference keys allowed in lifecycle audit payloads.""" + AUTHORIZATION_DECISION = "authorization_decision_id" PROJECT = "project_id" TASK = "task_id" ASSIGNMENT = "assignment_id" @@ -148,6 +153,14 @@ class LifecycleAuditReferenceKind(StrEnum): _LIFECYCLE_EVENT_ENTITY = { + **dict.fromkeys( + ( + LifecycleAuditEventType.TASK_CLAIMED, + LifecycleAuditEventType.TASK_STARTED, + LifecycleAuditEventType.TASK_START_OVERRIDDEN, + ), + LifecycleAuditEntityType.TASK, + ), **dict.fromkeys( ( LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CREATED, @@ -195,6 +208,14 @@ class LifecycleAuditReferenceKind(StrEnum): } _LIFECYCLE_EVENT_REQUIRED_REFERENCES = { + **dict.fromkeys( + ( + LifecycleAuditEventType.TASK_CLAIMED, + LifecycleAuditEventType.TASK_STARTED, + LifecycleAuditEventType.TASK_START_OVERRIDDEN, + ), + frozenset({LifecycleAuditReferenceKind.ASSIGNMENT, LifecycleAuditReferenceKind.AUTHORIZATION_DECISION}), + ), LifecycleAuditEventType.REVIEW_ACCEPTED: frozenset( {LifecycleAuditReferenceKind.FINAL_ACCEPTANCE} ), @@ -231,6 +252,7 @@ class LifecycleAuditEventInput(BaseModel): event_type: LifecycleAuditEventType actor_id: UUID reason: LifecycleAuditReason + task_reason: Annotated[str, Field(min_length=1, max_length=1000)] | None = None from_status: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]{0,29}$")] | None = None to_status: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]{0,29}$")] | None = None references: dict[LifecycleAuditReferenceKind, UUID] = Field(default_factory=dict) @@ -258,6 +280,20 @@ def admit_closed_input(cls, value: object) -> object: @model_validator(mode="after") def validate_lifecycle_shape(self) -> Self: """Keep state transitions distinct from immutable fact creation.""" + if self.entity_type is LifecycleAuditEntityType.TASK: + expected = { + LifecycleAuditEventType.TASK_CLAIMED: ("ready", "claimed"), + LifecycleAuditEventType.TASK_STARTED: ("claimed", "in_progress"), + LifecycleAuditEventType.TASK_START_OVERRIDDEN: ("claimed", "in_progress"), + }.get(self.event_type) + if expected != (self.from_status, self.to_status): + raise ValueError("task event requires its exact transition") + if self.task_reason is not None and not self.task_reason.strip(): + raise ValueError("task reason must not be blank") + if self.event_type is LifecycleAuditEventType.TASK_START_OVERRIDDEN and self.task_reason is None: + raise ValueError("task start override requires a reason") + elif self.task_reason is not None: + raise ValueError("task reason is restricted to task transitions") if self.reason is LifecycleAuditReason.STATE_CHANGED: if ( self.from_status is None @@ -268,6 +304,7 @@ def validate_lifecycle_shape(self) -> Self: elif self.from_status is not None or self.to_status is not None: raise ValueError("fact recording cannot carry lifecycle states") entity_reference = { + LifecycleAuditEntityType.TASK: LifecycleAuditReferenceKind.TASK, LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY: LifecycleAuditReferenceKind.REVIEW_QUEUE_ENTRY, LifecycleAuditEntityType.REVIEW_LEASE: LifecycleAuditReferenceKind.REVIEW_LEASE, LifecycleAuditEntityType.REVIEW: LifecycleAuditReferenceKind.REVIEW, diff --git a/backend/app/modules/audit/service.py b/backend/app/modules/audit/service.py index a32560aa7..518927691 100644 --- a/backend/app/modules/audit/service.py +++ b/backend/app/modules/audit/service.py @@ -92,7 +92,7 @@ async def add_event(self, value: LifecycleAuditEventInput) -> AuditEvent: claim_snapshot={}, auth_source=LIFECYCLE_AUTH_SOURCE, is_dev_auth=False, - reason=value.reason.value, + reason=value.task_reason if value.task_reason is not None else value.reason.value, event_payload={"references": references}, event_domain="legacy_lifecycle", event_version=None, diff --git a/backend/app/modules/authorization/artifact_project_authority.py b/backend/app/modules/authorization/artifact_project_authority.py index 2a6adc03e..f06e95bf7 100644 --- a/backend/app/modules/authorization/artifact_project_authority.py +++ b/backend/app/modules/authorization/artifact_project_authority.py @@ -1,8 +1,11 @@ -"""Narrow kernel rules for human project-scoped ART actions.""" +"""Shared project-authority locks and ART resource evaluation for the AUTH kernel.""" from __future__ import annotations -from app.modules.authorization.catalogue import ActionAvailability +from app.modules.authorization.catalogue import ActionAvailability, ActionId +from app.modules.authorization.domain.task_authority import ( + TASK_ACTIONS, TASK_SUBMITTER_ACTIONS, evaluate_task_authority, +) from app.modules.authorization.runtime import ( AuthorizationDenialCode, HumanAuthorizationContext, @@ -14,8 +17,30 @@ ) -async def lock_guide_ingest_authority(repository, context, scope, permission_id, locked_context): - """Lock the exact human identity and project-scoped guide-ingest grant.""" +PROJECT_SUBMITTER_ACTIONS = TASK_SUBMITTER_ACTIONS | { + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE, +} +PROJECT_AUTHORITY_ACTIONS = TASK_ACTIONS | PROJECT_SUBMITTER_ACTIONS | { + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, +} + + +async def lock_project_authority(repository, context, scope, action, locked_context): + """Dispatch the closed project-action set to its existing authority owner.""" + if action.action_id in PROJECT_SUBMITTER_ACTIONS: + return await lock_submitter_authority(repository, context, scope, locked_context) + if action.action_id not in PROJECT_AUTHORITY_ACTIONS: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) + return await lock_project_admin_authority( + repository, context, scope, action.permission_id, locked_context, + system_scope_only=action.action_id is ActionId.OPERATIONS_TASK_START_OVERRIDE, + ) + + +async def lock_project_admin_authority( + repository, context, scope, permission_id, locked_context, *, system_scope_only=False, +): + """Lock a project-covering admin grant, or require a system grant for override.""" if ( not isinstance(context, HumanAuthorizationContext) or scope.kind is not PreparedAuthorityScopeKind.PROJECT @@ -28,6 +53,7 @@ async def lock_guide_ingest_authority(repository, context, scope, permission_id, context.actor_profile_id, permission_id, scope_project_id=scope.project_id, + system_scope_only=system_scope_only, for_update=True, ) if grant is None: @@ -35,6 +61,17 @@ async def lock_guide_ingest_authority(repository, context, scope, permission_id, return context, grant +def evaluate_project_authority(action, context, authority, resource, lifecycle_denial): + """Keep each resource guard distinct while sharing the closed dispatch boundary.""" + if action.action_id in TASK_ACTIONS: + return evaluate_task_authority(action, context, authority, resource, lifecycle_denial) + if action.action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: + return evaluate_guide_ingest_authority(action, authority, resource, lifecycle_denial) + if action.action_id in PROJECT_SUBMITTER_ACTIONS: + return evaluate_submitter_authority(action, context, authority, resource, lifecycle_denial) + return AuthorizationDenialCode.ACTION_UNAVAILABLE, None, None, None + + def evaluate_guide_ingest_authority(action, authority, resource, lifecycle_denial): """Evaluate guide-ingest facts against the locked project authority.""" from app.modules.authorization.runtime import GuideSourceIngestResourceContext diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index b9b3840c0..be62da3df 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -92,6 +92,11 @@ class PermissionId(StrEnum): class ActionId(StrEnum): """Closed action identifiers reserved by approved owner chunks.""" + TASK_CLAIM = "task.claim" + TASK_START = "task.start" + TASK_WORK_CONTEXT_READ = "task.work_context.read" + PROJECT_TASK_WORK_CONTEXT_READ = "project.task.work_context.read" + ACTOR_PROFILE_READ_SELF = "actor.profile.read_self" ACTOR_PROFILE_UPDATE_SELF = "actor.profile.update_self" AUTHORIZATION_PERMISSION_CATALOGUE_READ = "authorization.permission_catalogue.read" @@ -216,6 +221,8 @@ class ActionId(StrEnum): class ActionOwner(StrEnum): """Closed implementation chunks allowed to activate reserved actions.""" + TASK_PROJECT_GRANT = "task-project-grant-authorization" + AUTH_07B = "WS-AUTH-001-07B" AUTH_08 = "WS-AUTH-001-08" AUTH_09B = "WS-AUTH-001-09B" @@ -239,7 +246,6 @@ class ActionOwner(StrEnum): XINT_002_06A = "WS-XINT-002-06A" AUTH_12G = "WS-AUTH-001-12G" AUTH_12H = "WS-AUTH-001-12H" - AUTH_13 = "WS-AUTH-001-13" AUTH_14 = "WS-AUTH-001-14" AUTH_REV_05 = "WS-AUTH-001-REV-05" AUTH_REV_06 = "WS-AUTH-001-REV-06" @@ -562,10 +568,14 @@ def _active( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H, ), - _planned( + _active(ActionId.TASK_CLAIM, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), + _active(ActionId.TASK_START, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), + _active(ActionId.TASK_WORK_CONTEXT_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.TASK_PROJECT_GRANT), + _active(ActionId.PROJECT_TASK_WORK_CONTEXT_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_PROJECT_GRANT), + _active( ActionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_TASK_START_OVERRIDE, - ActionOwner.AUTH_13, + ActionOwner.TASK_PROJECT_GRANT, ), _planned( ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, @@ -845,7 +855,7 @@ def _active( def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 73 or len(ACTION_IDS) != 110: + if len(PERMISSION_IDS) != 73 or len(ACTION_IDS) != 114: raise RuntimeError("authorization catalogue count mismatch") if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 24: raise RuntimeError("authorization permission boundary mismatch") @@ -935,7 +945,10 @@ def _index_actions( definition.action_id for definition in definitions if definition.availability is ActionAvailability.ACTIVE - } != active_actions: + } != active_actions | { + ActionId.TASK_CLAIM, ActionId.TASK_START, ActionId.TASK_WORK_CONTEXT_READ, + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, ActionId.OPERATIONS_TASK_START_OVERRIDE, + }: raise RuntimeError("authorization active action boundary mismatch") if set(definitions) != set(ACTION_DEFINITIONS): raise RuntimeError("authorization action metadata mismatch") diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index b07a0c2b7..abdf65e1f 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -2,9 +2,17 @@ from __future__ import annotations +from enum import StrEnum from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.catalogue import ActionId, PermissionId CONTEXT_DIGEST_RESOURCE_TYPES = ( + "task_authority", "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", @@ -24,6 +32,7 @@ AuthorizationDecisionResourceType = Literal[ + "task_authority", "actor_profile", "actor_authorization_context", "project", @@ -70,3 +79,117 @@ "compensation_adapter_binding", "contribution_policy", ] + + +class AuthorizationDenialCode(StrEnum): + """Closed internal authorization outcomes.""" + + UNKNOWN_ACTION = "unknown_action" + ACTION_UNAVAILABLE = "action_unavailable" + IDENTITY_LINK_REVOKED = "identity_link_revoked" + ACTOR_DEACTIVATED = "actor_deactivated" + ACTOR_SUSPENDED = "actor_suspended" + RESOURCE_GUARD_DENIED = "resource_guard_denied" + PERMISSION_NOT_GRANTED = "permission_not_granted" + SCOPE_NOT_AUTHORIZED = "scope_not_authorized" + SELF_GRANT_FORBIDDEN = "self_grant_forbidden" + SELF_ROLE_REVOKE_FORBIDDEN = "self_role_revoke_forbidden" + ACTOR_NOT_FOUND = "actor_not_found" + GRANT_NOT_FOUND = "grant_not_found" + RESOURCE_NOT_FOUND = "resource_not_found" + + +class MatchedAuthorityKind(StrEnum): + """Privacy-bounded authority source classifications.""" + ACTOR_SELF = "actor_self" + ADMIN_ROLE_GRANT = "admin_role_grant" + PROJECT_ROLE_GRANT = "project_role_grant" + FIXED_SERVICE = "fixed_service" + + +class AuthorizationDecision(BaseModel): + """Frozen decision safe for feature code, evidence, and error mapping.""" + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + decision_id: UUID + action_id: ActionId | None + permission_id: PermissionId | None + allowed: bool + denial_code: AuthorizationDenialCode | None + resource_type: AuthorizationDecisionResourceType + resource_id: ( + UUID + | ServiceIdentity + | Literal[ + "workstream:system", + "workstream:permission_catalogue", + "workstream:admin_role_definitions", + "workstream:admin_role_grants", + "workstream:artifact_pending_work", + ] + ) + resource_context_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + matched_authority_kind: MatchedAuthorityKind | None + matched_grant_id: UUID | None = None + matched_scope_project_id: UUID | None = None + revalidated: bool + request_id: UUID + correlation_id: UUID + + @model_validator(mode="after") + def validate_outcome(self): + """Keep allow and deny fields mutually coherent.""" + if self.allowed != (self.denial_code is None): + raise ValueError("authorization outcome is inconsistent") + if self.allowed != (self.matched_authority_kind is not None): + raise ValueError("authorization authority match is inconsistent") + if (self.action_id is None) != (self.permission_id is None): + raise ValueError("action and permission must be present together") + if self.allowed and self.action_id is None: + raise ValueError("allowed decisions require action and permission") + if self.matched_authority_kind is MatchedAuthorityKind.ACTOR_SELF: + if self.matched_grant_id is not None or self.matched_scope_project_id is not None: + raise ValueError("actor-self decisions cannot carry grant scope") + elif self.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT: + if self.matched_grant_id is None: + raise ValueError("grant decisions require matched grant") + elif self.matched_authority_kind is MatchedAuthorityKind.PROJECT_ROLE_GRANT: + if self.matched_grant_id is None or self.matched_scope_project_id is None: + raise ValueError("project-role decisions require matched grant and scope") + elif self.matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE: + if self.matched_grant_id is not None or self.matched_scope_project_id is not None: + raise ValueError("fixed-service decisions cannot carry grant scope") + elif self.matched_grant_id is not None or self.matched_scope_project_id is not None: + if ( + self.action_id + not in { + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + } + or self.matched_grant_id is None + or self.matched_scope_project_id is None + ): + raise ValueError("denied decision carries invalid matched-grant provenance") + return self + + +class AuthorizationDenied(Exception): + def __init__(self, decision: AuthorizationDecision) -> None: + if decision.allowed or decision.denial_code is None: + raise TypeError("authorization denial requires a denied decision") + self.decision = decision + super().__init__("Authorization denied") + + @property + def public_code(self) -> str: + denial_code = self.decision.denial_code + if denial_code is None: + raise RuntimeError("authorization denial lost its denial code") + if denial_code in { + AuthorizationDenialCode.UNKNOWN_ACTION, + AuthorizationDenialCode.ACTION_UNAVAILABLE, + }: + return AuthorizationDenialCode.PERMISSION_NOT_GRANTED.value + return denial_code.value +class AuthorizationEvidenceUnavailable(RuntimeError): + pass diff --git a/backend/app/modules/authorization/domain/audit_targets.py b/backend/app/modules/authorization/domain/audit_targets.py index fb1401c96..ae6141e44 100644 --- a/backend/app/modules/authorization/domain/audit_targets.py +++ b/backend/app/modules/authorization/domain/audit_targets.py @@ -1,7 +1,16 @@ """Exact project resource audit selectors, without raw product facts.""" +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.action_groups import ( + GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS, SUBMISSION_POLICY_MUTATIONS, +) +from app.modules.authorization.domain.project_create import ProjectCreateResourceContext +from app.modules.authorization.domain.task_authority import TaskAuthorityResourceContext -def project_authority_audit_target(resource: object) -> tuple[str, str, str, str, str] | None: + +def project_authority_audit_target( + resource: object, action_id: ActionId, +) -> tuple[str | None, str, str, str, str] | None: """Project-scoped exact contexts share bounded audit selectors, never raw facts.""" from app.modules.authorization.domain.contribution_policies import ( ContributionPolicyReadResourceContext, ContributionPolicyMutationResourceContext, @@ -20,9 +29,24 @@ def project_authority_audit_target(resource: object) -> tuple[str, str, str, str from app.modules.authorization.domain.project_setup_finalization import ( ProjectSetupFinalizationResourceContext, ) - from app.modules.authorization.runtime import PreSubmitCheckerInputResourceContext + from app.modules.authorization.runtime import ( + PreSubmitCheckerInputResourceContext, ProjectSubmissionArtifactPolicyMutationResourceContext, + ) - if not isinstance( + if isinstance(resource, TaskAuthorityResourceContext): + project_id = str(resource.scope_project_id) + return project_id, "project", project_id, "project", project_id + if isinstance(resource, ProjectCreateResourceContext): + return ( + None, "project_create_operation", str(resource.resource_id), + "project", str(resource.requested_project_id), + ) + if action_id in SUBMISSION_POLICY_MUTATIONS and isinstance( + resource, ProjectSubmissionArtifactPolicyMutationResourceContext, + ): + project_id = str(resource.scope_project_id) + return project_id, resource.resource_type, str(resource.resource_id), "project", project_id + if isinstance( resource, ( PreSubmitCheckerInputResourceContext, @@ -35,6 +59,11 @@ def project_authority_audit_target(resource: object) -> tuple[str, str, str, str ProjectSetupFinalizationResourceContext, ), ): - return None - project_id = str(getattr(resource, "project_id", None) or resource.scope_project_id) - return project_id, resource.resource_type, str(resource.resource_id), "project", project_id + project_id = str(getattr(resource, "project_id", None) or resource.scope_project_id) + return project_id, resource.resource_type, str(resource.resource_id), "project", project_id + if action_id in GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS: + scope_project_id = getattr(resource, "scope_project_id", None) + if scope_project_id is not None: + project_id = str(scope_project_id) + return project_id, "project", project_id, "project", project_id + return None diff --git a/backend/app/modules/authorization/domain/task_authority.py b/backend/app/modules/authorization/domain/task_authority.py new file mode 100644 index 000000000..a2fac35e9 --- /dev/null +++ b/backend/app/modules/authorization/domain/task_authority.py @@ -0,0 +1,110 @@ +"""Exact resource and prepared rules for the bounded task-authority cutover.""" + +import json +from collections.abc import Mapping +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.modules.authorization.catalogue import ActionAvailability, ActionId +from app.modules.authorization.domain.audit import AuthorizationDenialCode, MatchedAuthorityKind + + +TASK_SUBMITTER_ACTIONS = frozenset( + { + ActionId.TASK_CLAIM, + ActionId.TASK_START, + ActionId.TASK_WORK_CONTEXT_READ, + } +) +TASK_ACTIONS = TASK_SUBMITTER_ACTIONS | { + ActionId.OPERATIONS_TASK_START_OVERRIDE, + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, +} + + +class TaskAuthorityResourceContext(BaseModel): + """TASK-locked facts remain exact through AUTH preparation and consumption.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + resource_type: Literal["task_authority"] = "task_authority" + resource_id: UUID + scope_project_id: UUID + actor_profile_id: UUID + identity_link_id: UUID + task_status: str + assigned_to: UUID | None + assignment_id: UUID | None + assignment_contributor_id: UUID | None + locked_context_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + reason: str | None + + +def parse_task_authority_binding( + action: ActionId, request: Mapping[str, object], invalid_error: type[Exception], +) -> TaskAuthorityResourceContext | None: + """Keep the complete task commitment typed and immutable through consumption.""" + if action not in TASK_ACTIONS: + return None + try: + return TaskAuthorityResourceContext.model_validate_json(json.dumps(dict(request))) + except (TypeError, ValueError) as exc: + raise invalid_error("invalid prepared authorization handle") from exc + + +def task_resource_guard(action: ActionId, resource: TaskAuthorityResourceContext) -> bool: + """No authority allow may substitute for assignment and task currentness.""" + own_assignment = ( + resource.assignment_id is not None + and resource.assignment_contributor_id == resource.actor_profile_id + and resource.assigned_to == resource.actor_profile_id + ) + unassigned_ready = ( + resource.task_status == "ready" + and resource.assigned_to is None + and resource.assignment_id is None + and resource.assignment_contributor_id is None + ) + if action is ActionId.TASK_CLAIM: + return unassigned_ready + if action is ActionId.TASK_START: + return resource.task_status == "claimed" and own_assignment + if action is ActionId.OPERATIONS_TASK_START_OVERRIDE: + return ( + resource.task_status == "claimed" + and resource.assignment_id is not None + and resource.assignment_contributor_id is not None + and resource.assignment_contributor_id == resource.assigned_to + and resource.assigned_to != resource.actor_profile_id + and bool(resource.reason and resource.reason.strip()) + ) + if action is ActionId.TASK_WORK_CONTEXT_READ: + return unassigned_ready or own_assignment + return action is ActionId.PROJECT_TASK_WORK_CONTEXT_READ + + +def evaluate_task_authority(action, context, authority, resource, lifecycle_denial): + """Evaluate exact TASK facts against the authority already locked by AUTH.""" + denial = lifecycle_denial + if denial is None and action.availability is not ActionAvailability.ACTIVE: + denial = AuthorizationDenialCode.ACTION_UNAVAILABLE + if denial is None and ( + not isinstance(resource, TaskAuthorityResourceContext) + or resource.scope_project_id != authority.scope_project_id + or resource.actor_profile_id != context.actor_profile_id + or resource.identity_link_id != context.identity_link_id + or not task_resource_guard(action.action_id, resource) + ): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if denial is None and ( + authority.matched_grant_id is None or authority.matched_grant_status != "active" + ): + denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if denial is not None: + return denial, None, None, None + matched_kind = ( + MatchedAuthorityKind.PROJECT_ROLE_GRANT if action.action_id in TASK_SUBMITTER_ACTIONS + else MatchedAuthorityKind.ADMIN_ROLE_GRANT + ) + return None, matched_kind, authority.matched_grant_id, authority.scope_project_id diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 8e2ec4a29..b257db0db 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -32,6 +32,9 @@ project_setup_resource_matches, ) from app.modules.authorization.policy import ACTIVE_GUIDE_ADMIN_ROLES +from app.modules.authorization.domain.task_authority import ( + TASK_ACTIONS, TaskAuthorityResourceContext, +) from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.schemas import AdminRole from app.modules.authorization.runtime import ( @@ -93,10 +96,9 @@ authorization_resource_digest, ) from app.modules.authorization.artifact_project_authority import ( - evaluate_guide_ingest_authority, - evaluate_submitter_authority, - lock_guide_ingest_authority, - lock_submitter_authority, + PROJECT_AUTHORITY_ACTIONS, + evaluate_project_authority, + lock_project_authority, ) ContextRevalidator = Callable[ @@ -526,11 +528,9 @@ async def _prepare_prelocked( raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) - elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: - context, grant = await lock_guide_ingest_authority(self._admin, context, scope, action.permission_id, self._locked_human_context) - elif action_id in {ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE}: - context, grant = await lock_submitter_authority( - self._admin, context, scope, self._locked_human_context + elif action_id in PROJECT_AUTHORITY_ACTIONS: + context, grant = await lock_project_authority( + self._admin, context, scope, action, self._locked_human_context, ) else: raise PreparedAuthorizationUnsupported( @@ -622,6 +622,8 @@ async def _complete_prepared_denial( self._validate_prepared_consumer(consumer_token) action = ACTION_BY_ID.get(action_id) supported = ( + (action_id in TASK_ACTIONS and isinstance(resource_context, TaskAuthorityResourceContext)) + or ( action_id is ActionId.PROJECT_CREATE and isinstance(resource_context, ProjectCreateResourceContext) @@ -1039,13 +1041,9 @@ async def _require_prelocked( if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT matched_grant_id = authority.matched_grant_id - elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: - denial, matched_kind, matched_grant_id, matched_project_id = ( - evaluate_guide_ingest_authority(action, authority, resource_context, self._lifecycle_denial(context)) - ) - elif action_id in {ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE}: - denial, matched_kind, matched_grant_id, matched_project_id = ( - evaluate_submitter_authority(action, context, authority, resource_context, self._lifecycle_denial(context)) + elif action_id in PROJECT_AUTHORITY_ACTIONS: + denial, matched_kind, matched_grant_id, matched_project_id = evaluate_project_authority( + action, context, authority, resource_context, self._lifecycle_denial(context), ) else: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE @@ -1462,31 +1460,11 @@ async def _stage_decision( audit_resource_type = "actor_profile" elif decision.resource_type in {"actor_identity_link", "admin_role_grant"}: audit_resource_type = decision.resource_type - if isinstance(resource_context, ProjectCreateResourceContext): - audit_resource_type = "project_create_operation" - audit_resource_id = str(resource_context.resource_id) - target_ref_kind = "project" - target_ref_id = str(resource_context.requested_project_id) - elif decision.action_id in _SUBMISSION_POLICY_MUTATIONS and isinstance( - resource_context, ProjectSubmissionArtifactPolicyMutationResourceContext - ): - audit_project_id = str(resource_context.scope_project_id) - audit_resource_type = resource_context.resource_type - audit_resource_id = str(resource_context.resource_id) - target_ref_kind = "project" - target_ref_id = str(resource_context.scope_project_id) - elif exact_project_target := project_authority_audit_target(resource_context): - (audit_project_id, audit_resource_type, audit_resource_id, + if exact_project_target := project_authority_audit_target(resource_context, decision.action_id): + (target_project_id, audit_resource_type, audit_resource_id, target_ref_kind, target_ref_id) = exact_project_target - elif decision.action_id in _GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS: - if resource_context is not None: - project_id = self._resource_project_id(resource_context) - if project_id is not None: - audit_project_id = str(project_id) - audit_resource_type = "project" - audit_resource_id = str(project_id) - target_ref_kind = "project" - target_ref_id = str(project_id) + if target_project_id is not None: + audit_project_id = target_project_id after_facts: dict[str, object] = {"allowed": decision.allowed} if decision.resource_type in CONTEXT_DIGEST_RESOURCE_TYPES or decision.action_id in CONTEXT_DIGEST_ACTIONS: after_facts["resource_context_digest"] = decision.resource_context_digest diff --git a/backend/app/modules/authorization/pre_submit_materialization.py b/backend/app/modules/authorization/pre_submit_materialization.py index e02c7d885..8df17d005 100644 --- a/backend/app/modules/authorization/pre_submit_materialization.py +++ b/backend/app/modules/authorization/pre_submit_materialization.py @@ -3,7 +3,13 @@ from uuid import UUID from app.core.hashing import canonical_json_hash +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.resource_digest import authorization_resource_digest from app.modules.authorization.runtime import PreSubmitCheckerInputPreparationContext +from app.modules.authorization.submission_consumption import parse_consumption_binding +from app.modules.authorization.submission_preparation import ( + parse_submission_preparation_or_invalid, submission_preparation_binding_fields, +) def parse_materialization_binding(raw: dict, invalid_error) -> tuple[dict, str]: @@ -23,14 +29,23 @@ def parse_materialization_binding(raw: dict, invalid_error) -> tuple[dict, str]: return context, canonical_json_hash({"pre_submit_checker_input_preparation": context}) -def initialize_artifact_bindings() -> tuple[None, None, None, None, None, None]: - """Return empty prepared-artifact binding slots.""" - return None, None, None, None, None, None - - -def parse_submission_binding(raw: dict, invalid_error, parser) -> tuple: - """Delegate submission preparation parsing through its bounded parser.""" - return parser(raw, invalid_error) +def parse_prepared_artifact_bindings(action_id: ActionId, raw: dict, invalid_error) -> dict: + """Bind only the exact artifact facts owned by the selected prepared action.""" + fields = {} + if action_id is ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: + context, digest = parse_materialization_binding(raw, invalid_error) + fields.update(exact_artifact_context=context, exact_artifact_resource_digest=digest) + consumption = parse_consumption_binding(action_id, raw, invalid_error) + if consumption is not None: + fields.update( + exact_artifact_context=consumption.model_dump(mode="json"), + exact_artifact_resource_digest=authorization_resource_digest(consumption), + ) + if action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: + fields.update(submission_preparation_binding_fields( + parse_submission_preparation_or_invalid(raw, invalid_error), + )) + return fields def parse_project_create_binding(raw: dict, invalid_error): diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 8f546e860..e65bb453a 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -94,18 +94,16 @@ ServiceAuthorizationContext, ) from app.modules.authorization.submission_preparation import ( - parse_submission_preparation_or_invalid, - submission_preparation_binding_fields, submission_preparation_binding_matches, SubmissionBundlePreparationPreflightResourceContext, SubmissionBundlePreparationResourceContext, ) -from app.modules.authorization.submission_consumption import parse_consumption_binding +from app.modules.authorization.domain.task_authority import ( + TASK_ACTIONS, TaskAuthorityResourceContext, parse_task_authority_binding, +) from app.modules.authorization.pre_submit_materialization import ( - initialize_artifact_bindings, - parse_materialization_binding, + parse_prepared_artifact_bindings, parse_project_create_binding, - parse_submission_binding, ) @@ -167,6 +165,7 @@ class _PreparedAuthorizationBinding: scope: PreparedAuthorityScope idempotency_key: UUID request_digest: str + task_authority_context: TaskAuthorityResourceContext | None = None project_create_operation_id: UUID | None = None project_create_project_id: UUID | None = None project_create_generation: int | None = None @@ -468,6 +467,15 @@ async def preflight( finally: self._authorization._discard_prelocked(authority) + def _live_issuance(self, handle: PreparedAuthorizationHandle) -> _Issuance: + """Reject foreign, consumed and closed-session handles at the shared entry boundary.""" + if self._closed or type(handle) is not PreparedAuthorizationHandle: + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + issuance = self._issued.get(handle) + if not isinstance(issuance, _Issuance): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + return issuance + async def consume( self, handle: PreparedAuthorizationHandle, @@ -476,11 +484,7 @@ async def consume( final_resource_context: AuthorizationResourceContext, ) -> AuthorizationDecision: """Consume one exact capability before evaluating and evidencing final facts.""" - if self._closed or type(handle) is not PreparedAuthorizationHandle: - raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") - issuance = self._issued.get(handle) - if issuance is None or issuance is _CONSUMED: - raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + issuance = self._live_issuance(handle) if expected_action_id is not issuance.binding.action_id: raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") rebound = self._binding(expected_action_id, caller_input, issuance.binding.scope) @@ -492,6 +496,11 @@ async def consume( final_scope = self._scope_from_resource(expected_action_id, final_resource_context) if final_scope != issuance.binding.scope: raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if expected_action_id in TASK_ACTIONS and ( + not isinstance(final_resource_context, TaskAuthorityResourceContext) + or issuance.binding.task_authority_context != final_resource_context + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") if isinstance(final_resource_context, ProjectCreateResourceContext) and not ( _project_create_binding_matches(issuance.binding, final_resource_context) ): @@ -576,11 +585,7 @@ async def validate_replay( final_resource_context: AuthorizationResourceContext, stored_decision_id: UUID, ) -> None: - if self._closed or type(handle) is not PreparedAuthorizationHandle: - raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") - issuance = self._issued.get(handle) - if not isinstance(issuance, _Issuance): - raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + issuance = self._live_issuance(handle) self._issued[handle] = _CONSUMED try: await validate_projection_replay( @@ -682,25 +687,7 @@ def _binding( policy_mutation_generation = policy_mutation_predecessor_generation = policy_mutation_predecessor_id = policy_mutation_guide_status = None sufficiency: dict[str, object] = {} submission_policy_context = submission_policy_resource_digest = None - exact_artifact_context, exact_artifact_resource_digest, submission_preparation_context, submission_preparation_resource_digest, submission_preparation_final_context, submission_preparation_final_digest = initialize_artifact_bindings() setup_bindings = parse_setup_bindings(action_id, caller_input, scope, self._context) - if action_id is ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: - exact_artifact_context, exact_artifact_resource_digest = parse_materialization_binding( - dict(caller_input.request_value), PreparedAuthorizationHandleInvalid - ) - consumption_resource = parse_consumption_binding( - action_id, caller_input.request_value, PreparedAuthorizationHandleInvalid - ) - if consumption_resource is not None: - exact_artifact_context = consumption_resource.model_dump(mode="json") - exact_artifact_resource_digest = authorization_resource_digest(consumption_resource) - if action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: - (submission_preparation_context, submission_preparation_resource_digest, - submission_preparation_final_context, submission_preparation_final_digest) = parse_submission_binding( - dict(caller_input.request_value), - PreparedAuthorizationHandleInvalid, - parse_submission_preparation_or_invalid, - ) if action_id is ActionId.PROJECT_CREATE: operation_id, project_id, operation_generation = parse_project_create_binding( dict(caller_input.request_value), PreparedAuthorizationHandleInvalid @@ -890,15 +877,11 @@ def _binding( ), submission_policy_context=submission_policy_context, submission_policy_resource_digest=submission_policy_resource_digest, - exact_artifact_context=exact_artifact_context, - exact_artifact_resource_digest=exact_artifact_resource_digest, - **submission_preparation_binding_fields( - ( - submission_preparation_context, - submission_preparation_resource_digest, - submission_preparation_final_context, - submission_preparation_final_digest, - ) + task_authority_context=parse_task_authority_binding( + action_id, caller_input.request_value, PreparedAuthorizationHandleInvalid, + ), + **parse_prepared_artifact_bindings( + action_id, dict(caller_input.request_value), PreparedAuthorizationHandleInvalid, ), **parse_prepared_adapter_binding(action_id, caller_input.request_value), **parse_prepared_contribution_policy(action_id, caller_input.request_value), @@ -931,9 +914,11 @@ def _scope_from_resource( ) if admin_scope := _admin_prepared_scope(action_id, resource): return admin_scope - expected_project_mutation = PROJECT_MUTATION_RESOURCE_BY_ACTION.get( - action_id - ) or COMPILATION_RESOURCE_BY_ACTION.get(action_id) + expected_project_resource = ( + PROJECT_MUTATION_RESOURCE_BY_ACTION.get(action_id) + or COMPILATION_RESOURCE_BY_ACTION.get(action_id) + or (TaskAuthorityResourceContext if action_id in TASK_ACTIONS else None) + ) if action_id in CONTRIBUTION_POLICY_MUTATION_ACTIONS and isinstance(resource, ContributionPolicyMutationResourceContext): return PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id) if action_id in ADAPTER_BINDING_MUTATION_ACTIONS and isinstance( @@ -960,8 +945,8 @@ def _scope_from_resource( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, ) - if expected_project_mutation is not None and isinstance( - resource, expected_project_mutation + if expected_project_resource is not None and isinstance( + resource, expected_project_resource ): if isinstance(resource, ProjectCreateResourceContext): return PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index bf2f34f74..009b7178e 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -12,16 +12,23 @@ from app.modules.authorization.domain.resource_digest import authorization_resource_digest as authorization_resource_digest from app.modules.authorization.domain.project_setup_finalization import ProjectSetupFinalizationResourceContext from app.modules.authorization.domain.guide_compilation_projections import ProjectGuideProjectionResourceContext -from app.modules.authorization.domain.audit import AuthorizationDecisionResourceType +from app.modules.authorization.domain.audit import ( + AuthorizationDecision as AuthorizationDecision, + AuthorizationDenialCode as AuthorizationDenialCode, + AuthorizationDenied as AuthorizationDenied, + AuthorizationEvidenceUnavailable as AuthorizationEvidenceUnavailable, + MatchedAuthorityKind as MatchedAuthorityKind, +) from app.modules.authorization.domain.contribution_policies import ContributionPolicyReadResourceContext, ContributionPolicyMutationResourceContext from app.modules.authorization.domain.adapter_bindings import AdapterBindingMutationResourceContext, AdapterBindingReadResourceContext from app.modules.authorization.domain.project_create import ProjectCreateResourceContext +from app.modules.authorization.domain.task_authority import TaskAuthorityResourceContext from app.modules.authorization.domain.guide_mutations import ( ProjectGuideMutationResourceContext, ProjectGuideMutationPrepareDenialResourceContext, ProjectGuideSourceSnapshotMutationResourceContext, ) from app.modules.actors.service_identities import ServiceIdentity -from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.catalogue import ActionId from app.modules.authorization.schemas import AdminRole, AdminScope, ProjectRole from app.modules.authorization.submission_preparation import SubmissionBundlePreparationPreflightResourceContext, SubmissionBundlePreparationResourceContext from app.modules.authorization.submission_consumption import SubmissionBindingResourceContext, SubmissionCreationResourceContext @@ -1364,6 +1371,8 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte AuthorizationResourceContext = ( + TaskAuthorityResourceContext + | ActorSelfResourceContext | ProjectReadResourceContext | ProjectDiagnosticReadResourceContext @@ -1421,117 +1430,3 @@ def authorization_resource_selector_id(resource_type: str, raw_id: str) -> UUID: return UUID(raw_id) except (TypeError, ValueError, AttributeError): return uuid5(NAMESPACE_URL, f"workstream:{resource_type}-selector:{raw_id}") - - -class AuthorizationDenialCode(StrEnum): - """Closed internal authorization outcomes.""" - - UNKNOWN_ACTION = "unknown_action" - ACTION_UNAVAILABLE = "action_unavailable" - IDENTITY_LINK_REVOKED = "identity_link_revoked" - ACTOR_DEACTIVATED = "actor_deactivated" - ACTOR_SUSPENDED = "actor_suspended" - RESOURCE_GUARD_DENIED = "resource_guard_denied" - PERMISSION_NOT_GRANTED = "permission_not_granted" - SCOPE_NOT_AUTHORIZED = "scope_not_authorized" - SELF_GRANT_FORBIDDEN = "self_grant_forbidden" - SELF_ROLE_REVOKE_FORBIDDEN = "self_role_revoke_forbidden" - ACTOR_NOT_FOUND = "actor_not_found" - GRANT_NOT_FOUND = "grant_not_found" - RESOURCE_NOT_FOUND = "resource_not_found" - - -class MatchedAuthorityKind(StrEnum): - """Privacy-bounded authority source classifications.""" - ACTOR_SELF = "actor_self" - ADMIN_ROLE_GRANT = "admin_role_grant" - PROJECT_ROLE_GRANT = "project_role_grant" - FIXED_SERVICE = "fixed_service" - - -class AuthorizationDecision(BaseModel): - """Frozen decision safe for feature code, evidence, and error mapping.""" - model_config = _STRICT_FROZEN - decision_id: UUID - action_id: ActionId | None - permission_id: PermissionId | None - allowed: bool - denial_code: AuthorizationDenialCode | None - resource_type: AuthorizationDecisionResourceType - resource_id: ( - UUID - | ServiceIdentity - | Literal[ - "workstream:system", - "workstream:permission_catalogue", - "workstream:admin_role_definitions", - "workstream:admin_role_grants", - "workstream:artifact_pending_work", - ] - ) - resource_context_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") - matched_authority_kind: MatchedAuthorityKind | None - matched_grant_id: UUID | None = None - matched_scope_project_id: UUID | None = None - revalidated: bool - request_id: UUID - correlation_id: UUID - - @model_validator(mode="after") - def validate_outcome(self): - """Keep allow and deny fields mutually coherent.""" - if self.allowed != (self.denial_code is None): - raise ValueError("authorization outcome is inconsistent") - if self.allowed != (self.matched_authority_kind is not None): - raise ValueError("authorization authority match is inconsistent") - if (self.action_id is None) != (self.permission_id is None): - raise ValueError("action and permission must be present together") - if self.allowed and self.action_id is None: - raise ValueError("allowed decisions require action and permission") - if self.matched_authority_kind is MatchedAuthorityKind.ACTOR_SELF: - if self.matched_grant_id is not None or self.matched_scope_project_id is not None: - raise ValueError("actor-self decisions cannot carry grant scope") - elif self.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT: - if self.matched_grant_id is None: - raise ValueError("grant decisions require matched grant") - elif self.matched_authority_kind is MatchedAuthorityKind.PROJECT_ROLE_GRANT: - if self.matched_grant_id is None or self.matched_scope_project_id is None: - raise ValueError("project-role decisions require matched grant and scope") - elif self.matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE: - if self.matched_grant_id is not None or self.matched_scope_project_id is not None: - raise ValueError("fixed-service decisions cannot carry grant scope") - elif self.matched_grant_id is not None or self.matched_scope_project_id is not None: - if ( - self.action_id - not in { - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - } - or self.matched_grant_id is None - or self.matched_scope_project_id is None - ): - raise ValueError("denied decision carries invalid matched-grant provenance") - return self - - -class AuthorizationDenied(Exception): - def __init__(self, decision: AuthorizationDecision) -> None: - if decision.allowed or decision.denial_code is None: - raise TypeError("authorization denial requires a denied decision") - self.decision = decision - super().__init__("Authorization denied") - - @property - def public_code(self) -> str: - denial_code = self.decision.denial_code - if denial_code is None: - raise RuntimeError("authorization denial lost its denial code") - if denial_code in { - AuthorizationDenialCode.UNKNOWN_ACTION, - AuthorizationDenialCode.ACTION_UNAVAILABLE, - }: - return AuthorizationDenialCode.PERMISSION_NOT_GRANTED.value - return denial_code.value -class AuthorizationEvidenceUnavailable(RuntimeError): - pass diff --git a/backend/app/modules/authorization/task_authorization.py b/backend/app/modules/authorization/task_authorization.py new file mode 100644 index 000000000..09aa03efc --- /dev/null +++ b/backend/app/modules/authorization/task_authorization.py @@ -0,0 +1,130 @@ +"""AUTH-owned adapter for exact prepared task operations.""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.tasks.api import TaskAuthorityDenied, TaskAuthorityFacts +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.task_authority import TaskAuthorityResourceContext +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationHandle, PreparedAuthorizationService +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + AuthorizationContext, + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, +) + + +class PreparedTaskAuthorization: + """Reuse the kernel's locks, single-use handles and exact decision audit.""" + + def __init__(self, session: AsyncSession, context: AuthorizationContext) -> None: + self._context = context + self._session = session + self._repository = AdminAuthorizationRepository(session) + self._kernel = AuthorizationService(session, context, admin_repository=self._repository) + + def _resource(self, facts: TaskAuthorityFacts) -> TaskAuthorityResourceContext: + if facts.actor_profile_id != self._context.actor_profile_id: + raise TaskAuthorityDenied("task authority denied") + # Service actors reach the canonical fixed-service matrix denial so + # their rejected request retains the same AUTH audit custody as humans. + return TaskAuthorityResourceContext( + resource_id=facts.task_id, + scope_project_id=facts.project_id, + actor_profile_id=facts.actor_profile_id, + identity_link_id=self._context.identity_link_id, + task_status=facts.task_status, + assigned_to=facts.assigned_to, + assignment_id=facts.assignment_id, + assignment_contributor_id=facts.assignment_contributor_id, + locked_context_hash=facts.locked_context_hash, + reason=facts.reason, + ) + + async def prepare(self, facts: TaskAuthorityFacts) -> object: + resource = self._resource(facts) + service = PreparedAuthorizationService( + self._session, + self._context, + self._kernel, + self._repository, + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=self._context.request_id, + request_value=resource.model_dump(mode="json"), + ) + try: + handle = await service.prepare( + ActionId(facts.operation.value), + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=facts.project_id, + ), + ) + return _TaskPrepared(service, handle, caller_input) + except PreparedAuthorizationUnsupported as exc: + try: + await service.deny_unsupported( + ActionId(facts.operation.value), caller_input, resource, exc + ) + except AuthorizationDenied as denial: + raise TaskAuthorityDenied("task authority denied") from denial + finally: + service.close() + except (AuthorizationDenied, PreparedAuthorizationHandleInvalid) as exc: + service.close() + raise TaskAuthorityDenied("task authority denied") from exc + except BaseException: + service.close() + raise + + async def consume(self, handle: object, facts: TaskAuthorityFacts) -> UUID: + if not isinstance(handle, _TaskPrepared): + raise TaskAuthorityDenied("task authority denied") + try: + decision = await handle.service.consume( + handle.handle, + ActionId(facts.operation.value), + handle.caller_input, + self._resource(facts), + ) + return decision.decision_id + except ( + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ) as exc: + raise TaskAuthorityDenied("task authority denied") from exc + finally: + handle.service.close() + + def close(self, handle: object) -> None: + if isinstance(handle, _TaskPrepared): + handle.service.close() + + async def restage_denial(self, error: TaskAuthorityDenied) -> bool: + """Reuse the issuing kernel's exact pending evidence after rollback.""" + if isinstance(error.__cause__, AuthorizationDenied): + await self._kernel.restage_denial(error.__cause__.decision) + return True + return False + + +class _TaskPrepared: + __slots__ = ("service", "handle", "caller_input") + + def __init__( + self, service: PreparedAuthorizationService, handle: PreparedAuthorizationHandle, + caller_input: PreparedAuthorizationInput, + ) -> None: + self.service = service + self.handle = handle + self.caller_input = caller_input diff --git a/backend/app/modules/tasks/api/__init__.py b/backend/app/modules/tasks/api/__init__.py index 9b8c13ec7..4ede33eee 100644 --- a/backend/app/modules/tasks/api/__init__.py +++ b/backend/app/modules/tasks/api/__init__.py @@ -1,5 +1,14 @@ """Dependency-safe public API for the TASKS business module.""" +from app.modules.tasks.api.transition_audit import TaskTransitionAuditPort, TaskTransitionFacts + +from app.modules.tasks.api.authorization import ( + TaskAuthorizationPort, + TaskAuthorityDenied, + TaskAuthorityFacts, + TaskAuthorityOperation, +) + from app.modules.tasks.api.submission_context import ( SubmissionPredecessorFacts, TaskLockedProjectContextReferences, @@ -25,6 +34,12 @@ ) __all__ = ( + "TaskTransitionAuditPort", + "TaskTransitionFacts", + "TaskAuthorizationPort", + "TaskAuthorityDenied", + "TaskAuthorityFacts", + "TaskAuthorityOperation", "SubmissionPredecessorFacts", "TaskLockedProjectContextReferences", "TaskSubmissionContextFacts", diff --git a/backend/app/modules/tasks/api/authorization.py b/backend/app/modules/tasks/api/authorization.py new file mode 100644 index 000000000..829e86eb0 --- /dev/null +++ b/backend/app/modules/tasks/api/authorization.py @@ -0,0 +1,42 @@ +"""Exact task authority facts; decisions and grant ownership stay in AUTH.""" + +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol +from uuid import UUID + + +class TaskAuthorityOperation(StrEnum): + CLAIM = "task.claim" + START = "task.start" + START_OVERRIDE = "operations.task.start_override" + WORK_CONTEXT = "task.work_context.read" + MANAGEMENT_WORK_CONTEXT = "project.task.work_context.read" + + +@dataclass(frozen=True, slots=True) +class TaskAuthorityFacts: + operation: TaskAuthorityOperation + task_id: UUID + project_id: UUID + actor_profile_id: UUID + task_status: str + assigned_to: UUID | None + assignment_id: UUID | None + assignment_contributor_id: UUID | None + locked_context_hash: str + reason: str | None = None + + +class TaskAuthorityDenied(RuntimeError): + """The exact task operation has no current canonical authority.""" + + +class TaskAuthorizationPort(Protocol): + """Prepare with TASK/assignment locked; consume exact facts before writes.""" + + async def prepare(self, facts: TaskAuthorityFacts) -> object: ... + + async def consume(self, handle: object, facts: TaskAuthorityFacts) -> UUID: ... + + def close(self, handle: object) -> None: ... diff --git a/backend/app/modules/tasks/api/transition_audit.py b/backend/app/modules/tasks/api/transition_audit.py new file mode 100644 index 000000000..562d3528f --- /dev/null +++ b/backend/app/modules/tasks/api/transition_audit.py @@ -0,0 +1,24 @@ +"""TASK transition facts passed to the shared audit owner.""" + +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +from app.modules.tasks.api.authorization import TaskAuthorityOperation + + +@dataclass(frozen=True, slots=True) +class TaskTransitionFacts: + operation: TaskAuthorityOperation + project_id: UUID + task_id: UUID + assignment_id: UUID + actor_profile_id: UUID + authorization_decision_id: UUID + from_status: str + to_status: str + reason: str | None + + +class TaskTransitionAuditPort(Protocol): + async def record(self, facts: TaskTransitionFacts) -> None: ... diff --git a/backend/app/modules/tasks/authorized_commands.py b/backend/app/modules/tasks/authorized_commands.py new file mode 100644 index 000000000..b960c6d03 --- /dev/null +++ b/backend/app/modules/tasks/authorized_commands.py @@ -0,0 +1,225 @@ +"""Canonical project-authorized task commands with one transaction owner.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.db.errors import integrity_constraint_name +from app.modules.tasks.api.authorization import ( + TaskAuthorityFacts, + TaskAuthorityOperation, + TaskAuthorizationPort, +) +from app.modules.tasks.api.transition_audit import TaskTransitionAuditPort, TaskTransitionFacts +from app.modules.tasks.models import TaskAssignment, WorkstreamTask +from app.modules.tasks.repository import TaskRepository +from app.modules.tasks.schemas import ( + AssignmentResponse, + TaskResponse, + TaskWithAssignmentResponse, + TaskWorkContextResponse, + TaskWorkerLifecycleContext, +) +from app.modules.tasks.service import ( + LOCKED_CONTEXT_REQUIRED_FIELDS, + TaskAssignmentConflict, + TaskNotFound, + TaskService, + TaskTransitionBlocked, + TaskValidationError, +) + + +class AuthorizedTaskCommands: + """TASK owns state and assignment; AUTH owns permission and current grants.""" + + def __init__( + self, + session: AsyncSession, + *, + authorization: TaskAuthorizationPort, + audit: TaskTransitionAuditPort, + actor_profile_id: UUID, + ) -> None: + self._session = session + self._authorization = authorization + self._audit = audit + self._actor_id = actor_profile_id + self._repo = TaskRepository(session) + self._contexts = TaskService(session) + + def _facts( + self, + task: WorkstreamTask, + assignment: TaskAssignment | None, + operation: TaskAuthorityOperation, + reason: str | None, + ) -> TaskAuthorityFacts: + return TaskAuthorityFacts( + operation=operation, + task_id=UUID(task.id), + project_id=UUID(task.project_id), + actor_profile_id=self._actor_id, + task_status=task.status, + assigned_to=UUID(task.assigned_to) if task.assigned_to else None, + assignment_id=UUID(assignment.id) if assignment else None, + assignment_contributor_id=UUID(assignment.contributor_id) if assignment else None, + locked_context_hash=canonical_json_hash( + {field: getattr(task, field) for field in LOCKED_CONTEXT_REQUIRED_FIELDS} + ), + reason=reason, + ) + + async def _locked_task( + self, + task_id: UUID, + operation: TaskAuthorityOperation, + reason: str | None = None, + project_id: UUID | None = None, + ) -> tuple[WorkstreamTask, TaskAssignment | None, UUID]: + # Match submission creation: TASK/assignment locks precede AUTH locks. + task = await self._repo.get_task(str(task_id), for_update=True) + if task is None or (project_id is not None and task.project_id != str(project_id)): + raise TaskNotFound("task not found") + assignment = await self._repo.get_active_assignment(task.id, for_update=True) + facts = self._facts(task, assignment, operation, reason) + handle = await self._authorization.prepare(facts) + try: + decision_id = await self._authorization.consume(handle, facts) + return task, assignment, decision_id + finally: + self._authorization.close(handle) + + async def claim(self, task_id: UUID, reason: str | None = None) -> TaskWithAssignmentResponse: + try: + async with self._session.begin(): + task, assignment, decision_id = await self._locked_task( + task_id, + TaskAuthorityOperation.CLAIM, + reason, + ) + self._contexts._ensure_transition_allowed(task.status, "claimed") + if assignment is not None or task.assigned_to is not None: + raise TaskAssignmentConflict("task already has an active assignment") + await self._contexts._load_locked_task_context(task) + assignment = await self._repo.add_assignment( + TaskAssignment( + id=str(uuid4()), + task_id=task.id, + contributor_id=str(self._actor_id), + assigned_by=str(self._actor_id), + accepted_at=datetime.now(UTC), + status="active", + ) + ) + task.assigned_to = str(self._actor_id) + await self._transition(task, assignment, "claimed", decision_id, reason) + await self._session.flush() + await self._session.refresh(task) + response = TaskWithAssignmentResponse( + task=self._contexts.task_response_for_authority(task, can_manage=False), + assignment=AssignmentResponse.model_validate(assignment), + ) + return response + except IntegrityError as exc: + if integrity_constraint_name(exc) == "uq_task_assignments_one_active_per_task": + raise TaskAssignmentConflict("task already has an active assignment") from exc + raise + + async def start( + self, + task_id: UUID, + reason: str | None = None, + *, + operator_override: bool = False, + ) -> TaskResponse: + if operator_override and not (reason and reason.strip()): + raise TaskValidationError("operator start override reason is required") + operation = ( + TaskAuthorityOperation.START_OVERRIDE + if operator_override + else TaskAuthorityOperation.START + ) + async with self._session.begin(): + task, assignment, decision_id = await self._locked_task(task_id, operation, reason) + self._contexts._ensure_transition_allowed(task.status, "in_progress") + if assignment is None or assignment.contributor_id != task.assigned_to: + raise TaskTransitionBlocked("task has no consistent active assignment") + await self._contexts._load_locked_task_context(task) + await self._transition( + task, + assignment, + "in_progress", + decision_id, + reason, + operator_override=operator_override, + ) + await self._session.flush() + await self._session.refresh(task) + response = self._contexts.task_response_for_authority(task, can_manage=False) + return response + + async def work_context( + self, + task_id: UUID, + *, + project_id: UUID | None = None, + ) -> TaskWorkContextResponse: + operation = ( + TaskAuthorityOperation.MANAGEMENT_WORK_CONTEXT + if project_id is not None + else TaskAuthorityOperation.WORK_CONTEXT + ) + async with self._session.begin(): + task, assignment, _ = await self._locked_task(task_id, operation, project_id=project_id) + context = await self._contexts._load_locked_task_context(task) + own_assignment = bool( + assignment is not None + and assignment.contributor_id == str(self._actor_id) + and task.assigned_to == str(self._actor_id) + ) + actions = [] + if project_id is None: + if task.status == "ready" and assignment is None and task.assigned_to is None: + actions = ["claim"] + elif task.status == "claimed" and own_assignment: + actions = ["start"] + response = self._contexts._work_context_response( + task, + context, + lifecycle=TaskWorkerLifecycleContext( + status=task.status, + assigned_to_current_actor=own_assignment, + can_run_pre_submit_check=False, + can_submit=False, + next_actions=actions, + ), + ) + return response + + async def _transition( + self, + task: WorkstreamTask, + assignment: TaskAssignment, + status: str, + decision_id: UUID, + reason: str | None, + *, + operator_override: bool = False, + ) -> None: + before = task.status + task.status = status + await self._audit.record(TaskTransitionFacts( + operation=( + TaskAuthorityOperation.START_OVERRIDE if operator_override + else TaskAuthorityOperation.CLAIM if status == "claimed" + else TaskAuthorityOperation.START + ), + project_id=UUID(task.project_id), task_id=UUID(task.id), + assignment_id=UUID(assignment.id), actor_profile_id=self._actor_id, + authorization_decision_id=decision_id, + from_status=before, to_status=status, reason=reason, + )) diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index dac7b68ce..b375d18c7 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -2,23 +2,21 @@ from __future__ import annotations +from uuid import UUID +from app.api.deps.authorization import get_task_commands +from app.modules.tasks.authorized_commands import AuthorizedTaskCommands +from app.modules.tasks.api import TaskAuthorityOperation from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.auth import actor_registry_http_error, get_registered_actor +from app.api.deps.auth import get_registered_actor from app.core.api_controls import StructuredHTTPException, error_response from app.core.permissions import PermissionDenied from app.db.session import get_db_session -from app.modules.actors.schemas import ( - LegacyWorkflowEligibilityActivationRequest, - LegacyWorkflowEligibilityResponse, -) -from app.modules.actors.service import ActorRegistryError, ActorService from app.modules.tasks.schemas import ( AuditEventResponse, - SubmissionCreate, SubmissionRequirementsResponse, SubmissionResponse, TaskCreate, @@ -35,24 +33,6 @@ CANONICAL_ERROR_OBJECT_SCHEMA = {"$ref": "#/components/schemas/ApiError"} -PRE_SUBMIT_DOMAIN_ERROR_RESPONSE_SCHEMA = { - "oneOf": [ - { - "type": "object", - "required": ["code", "details", "error"], - "properties": { - "code": { - "type": "string", - "enum": ["pre_submission_checker_failed"], - }, - "details": {"type": "object"}, - "error": CANONICAL_ERROR_OBJECT_SCHEMA, - }, - "additionalProperties": False, - }, - {"$ref": "#/components/schemas/HTTPValidationError"}, - ] -} TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA = { @@ -75,6 +55,16 @@ } +TASK_LOCKED_CONTEXT_RESPONSES = { + 422: { + "description": "Locked task context is missing or inconsistent.", + "content": { + "application/json": {"schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA} + }, + } +} + + def task_http_error(exc: TaskServiceError) -> HTTPException: """Convert a service-layer task error into an HTTP error. @@ -102,10 +92,7 @@ def task_domain_error_response(request: Request, exc: TaskServiceError) -> JSONR code = getattr(exc, "code") details = getattr(exc, "details", None) or {} message = { - "pre_submission_checker_failed": "Pre-submission checks failed", "task_locked_context_invalid": "Task locked context is invalid", - "active_contributor_required": "Active contributor identity required", - "contributor_identity_unavailable": "Contributor identity verification unavailable", }[code] return error_response( request, @@ -130,24 +117,6 @@ def permission_http_error(exc: PermissionDenied) -> HTTPException: return HTTPException(status_code=403, detail=str(exc)) -@router.post( - "/workers/me/profile", - response_model=LegacyWorkflowEligibilityResponse, -) -async def ensure_worker_profile( - payload: LegacyWorkflowEligibilityActivationRequest, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> LegacyWorkflowEligibilityResponse: - """Activate bounded legacy intake metadata without creating authority.""" - try: - return await ActorService(session).activate_legacy_workflow_eligibility(actor, payload) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ActorRegistryError as exc: - raise actor_registry_http_error(exc) from exc - - @router.post( "/projects/{project_id}/tasks", response_model=TaskResponse, @@ -184,48 +153,11 @@ async def get_task( raise task_http_error(exc) from exc -@router.get( - "/tasks/{task_id}/work-context", - response_model=TaskWorkContextResponse, - response_model_exclude_none=True, - responses={ - 422: { - "description": "Locked task context is missing or inconsistent.", - "content": { - "application/json": {"schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA} - }, - } - }, -) -async def get_task_work_context( - request: Request, - task_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> TaskWorkContextResponse | JSONResponse: - """Return contributor-safe locked guide, policy, and lifecycle context.""" - try: - return await TaskService(session).get_task_work_context(actor, task_id) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except TaskServiceError as exc: - if getattr(exc, "code", None) is not None: - return task_domain_error_response(request, exc) - raise task_http_error(exc) from exc - - @router.get( "/tasks/{task_id}/submission-requirements", response_model=SubmissionRequirementsResponse, response_model_exclude_none=True, - responses={ - 422: { - "description": "Locked task context is missing or inconsistent.", - "content": { - "application/json": {"schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA} - }, - } - }, + responses=TASK_LOCKED_CONTEXT_RESPONSES, ) async def get_task_submission_requirements( request: Request, @@ -248,14 +180,7 @@ async def get_task_submission_requirements( "/tasks/{task_id}/locked-context", response_model=TaskLockedContextResponse, response_model_exclude_none=True, - responses={ - 422: { - "description": "Locked task context is missing or inconsistent.", - "content": { - "application/json": {"schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA} - }, - } - }, + responses=TASK_LOCKED_CONTEXT_RESPONSES, ) async def get_task_locked_context( request: Request, @@ -322,88 +247,6 @@ async def release_task( raise task_http_error(exc) from exc -@router.post( - "/tasks/{task_id}/claim", - response_model=TaskWithAssignmentResponse, - response_model_exclude_none=True, -) -async def claim_task( - task_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], - payload: TaskTransitionRequest | None = None, -) -> TaskWithAssignmentResponse: - """Claim a ready task for the current actor.""" - try: - return await TaskService(session).claim_task( - actor, - task_id, - None if payload is None else payload.reason, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except TaskServiceError as exc: - raise task_http_error(exc) from exc - - -@router.post( - "/tasks/{task_id}/start", - response_model=TaskResponse, - response_model_exclude_none=True, -) -async def start_task( - task_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], - payload: TaskTransitionRequest | None = None, -) -> TaskResponse: - """Move a claimed task into active work.""" - try: - return await TaskService(session).start_task( - actor, - task_id, - None if payload is None else payload.reason, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except TaskServiceError as exc: - raise task_http_error(exc) from exc - - -@router.post( - "/tasks/{task_id}/submissions", - response_model=SubmissionResponse, - response_model_exclude_none=True, - status_code=201, - responses={ - 422: { - "description": "Pre-submit domain failure or request validation error.", - "content": { - "application/json": { - "schema": PRE_SUBMIT_DOMAIN_ERROR_RESPONSE_SCHEMA, - } - }, - } - }, -) -async def create_submission( - request: Request, - task_id: str, - payload: SubmissionCreate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> SubmissionResponse | JSONResponse: - """Create a submission packet version for a task.""" - try: - return await TaskService(session).create_submission(actor, task_id, payload) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except TaskServiceError as exc: - if getattr(exc, "code", None) is not None: - return task_domain_error_response(request, exc) - raise task_http_error(exc) from exc - - @router.get( "/tasks/{task_id}/submissions", response_model=list[SubmissionResponse], @@ -474,3 +317,85 @@ async def list_task_audit_events( raise permission_http_error(exc) from exc except TaskServiceError as exc: raise task_http_error(exc) from exc + + +@router.post( + "/tasks/{task_id}/claim", response_model=TaskWithAssignmentResponse, response_model_exclude_none=True, + openapi_extra={"x-workstream-action-id": TaskAuthorityOperation.CLAIM.value}, +) +async def claim_task( + task_id: UUID, + commands: Annotated[AuthorizedTaskCommands, Depends(get_task_commands)], + payload: TaskTransitionRequest | None = None, +) -> TaskWithAssignmentResponse: + try: + return await commands.claim(task_id, payload.reason if payload else None) + except TaskServiceError as exc: + raise task_http_error(exc) from exc + + +@router.post( + "/tasks/{task_id}/start", response_model=TaskResponse, response_model_exclude_none=True, + openapi_extra={"x-workstream-action-id": TaskAuthorityOperation.START.value}, +) +async def start_task( + task_id: UUID, + commands: Annotated[AuthorizedTaskCommands, Depends(get_task_commands)], + payload: TaskTransitionRequest | None = None, +) -> TaskResponse: + try: + return await commands.start(task_id, payload.reason if payload else None) + except TaskServiceError as exc: + raise task_http_error(exc) from exc + + +@router.post( + "/operations/tasks/{task_id}/start", response_model=TaskResponse, response_model_exclude_none=True, + openapi_extra={"x-workstream-action-id": TaskAuthorityOperation.START_OVERRIDE.value}, +) +async def override_task_start( + task_id: UUID, + commands: Annotated[AuthorizedTaskCommands, Depends(get_task_commands)], + payload: TaskTransitionRequest, +) -> TaskResponse: + try: + return await commands.start(task_id, payload.reason, operator_override=True) + except TaskServiceError as exc: + raise task_http_error(exc) from exc + + +@router.get( + "/tasks/{task_id}/work-context", response_model=TaskWorkContextResponse, response_model_exclude_none=True, + openapi_extra={"x-workstream-action-id": TaskAuthorityOperation.WORK_CONTEXT.value}, + responses=TASK_LOCKED_CONTEXT_RESPONSES, +) +async def get_task_work_context( + request: Request, + task_id: UUID, + commands: Annotated[AuthorizedTaskCommands, Depends(get_task_commands)], +) -> TaskWorkContextResponse | JSONResponse: + try: + return await commands.work_context(task_id) + except TaskServiceError as exc: + if getattr(exc, "code", None) is not None: + return task_domain_error_response(request, exc) + raise task_http_error(exc) from exc + + +@router.get( + "/projects/{project_id}/tasks/{task_id}/work-context", response_model=TaskWorkContextResponse, response_model_exclude_none=True, + openapi_extra={"x-workstream-action-id": TaskAuthorityOperation.MANAGEMENT_WORK_CONTEXT.value}, + responses=TASK_LOCKED_CONTEXT_RESPONSES, +) +async def get_management_task_work_context( + request: Request, + project_id: UUID, + task_id: UUID, + commands: Annotated[AuthorizedTaskCommands, Depends(get_task_commands)], +) -> TaskWorkContextResponse | JSONResponse: + try: + return await commands.work_context(task_id, project_id=project_id) + except TaskServiceError as exc: + if getattr(exc, "code", None) is not None: + return task_domain_error_response(request, exc) + raise task_http_error(exc) from exc diff --git a/backend/app/modules/tasks/schemas.py b/backend/app/modules/tasks/schemas.py index 9fccc2061..53aa02c3c 100644 --- a/backend/app/modules/tasks/schemas.py +++ b/backend/app/modules/tasks/schemas.py @@ -93,7 +93,7 @@ class TaskTransitionRequest(BaseModel): model_config = ConfigDict(extra="forbid") - reason: str | None = None + reason: str | None = Field(default=None, max_length=1000) class EvidenceItemCreate(BaseModel): @@ -131,7 +131,7 @@ class ArtifactHashEntry(BaseModel): class SubmissionCreate(BaseModel): - """Request schema for creating a submission packet version.""" + """Shared packet value used by retained checker feedback and evaluation.""" model_config = ConfigDict(extra="forbid") diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 873276f3b..1dd71cb11 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -8,7 +8,6 @@ from typing import Any from uuid import uuid4 -from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.core.hashing import canonical_json_hash @@ -24,16 +23,8 @@ ) from app.modules.checkers.service import ( CheckerService, - CheckerServiceError, pre_review_gate_system_actor, ) -from app.modules.actors.models import LegacyWorkflowEligibility -from app.modules.actors.service import ( - ActiveHumanWriteActorRequired, - ActorService, - CanonicalWriteActorUnavailable, - LegacyWorkflowEligibilityCompatibility, -) from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, GuideSourceSnapshot, @@ -51,11 +42,8 @@ from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.tasks.authorization import can_admin_or_task_creator_manage from app.modules.tasks.lifecycle import ( - TASK_STATUS_CLAIMED, TASK_STATUS_DRAFT, TASK_STATUS_EVALUATION_PENDING, - TASK_STATUS_IN_PROGRESS, - TASK_STATUS_NEEDS_REVISION, TASK_STATUS_READY, TASK_STATUS_SCREENING, TASK_STATUS_SUBMITTED, @@ -64,22 +52,17 @@ ) from app.modules.tasks.models import ( AuditEvent, - EvidenceItem, Submission, - TaskAssignment, WorkstreamTask, ) from app.modules.tasks.repository import TaskRepository -from app.modules.tasks.submission_composition import build_submission from app.modules.tasks.schemas import ( - AssignmentResponse, AuditEventResponse, ForbiddenArtifactRequirement, PostSubmitPolicyBodySummary, RequiredArtifactRequirement, RequiredEvidenceRequirement, StorageReferenceRules, - SubmissionCreate, SubmissionRequirementsResponse, SubmissionResponse, TaskCreate, @@ -93,16 +76,11 @@ TaskWorkerLifecycleContext, TaskWorkerTaskContext, TaskWorkContextResponse, - TaskWithAssignmentResponse, ) from app.schemas.auth import ActorContext PROJECT_OPERATOR_ROLES = {"admin", "project_manager"} TASK_VIEW_ROLES = {"admin", "project_manager", "worker"} -TASK_CLAIM_ROLES = {"worker"} -TASK_SUBMIT_ROLES = {"worker"} -TASK_START_ROLES = {"admin", "project_manager", "worker"} -TASK_START_OPERATOR_ROLES = {"admin", "project_manager"} SUBMISSION_FINALIZE_ROLES = {"admin", "project_manager"} SUBMISSION_FINALIZED_EVENT_TYPE = "submission_finalized" PRE_REVIEW_GATE_DISPATCH_FAILED_EVENT_TYPE = "pre_review_gate_dispatch_failed" @@ -194,29 +172,6 @@ class TaskAssignmentConflict(TaskServiceError): status_code = 409 -class ActiveContributorRequired(TaskServiceError): - """Raised when the canonical caller cannot perform a contributor write.""" - - status_code = 403 - code = "active_contributor_required" - message = "Active contributor identity required" - - -class ContributorIdentityUnavailable(TaskServiceError): - """Raised when canonical contributor identity cannot be revalidated.""" - - status_code = 503 - code = "contributor_identity_unavailable" - message = "Contributor identity verification unavailable" - retryable = True - - -class LegacySubmitterEligibilityRequired(TaskServiceError): - """Raised when a submitter lacks temporary legacy workflow eligibility.""" - - status_code = 403 - - class SubmissionNotFound(TaskServiceError): """Raised when a submission id does not match a stored packet.""" @@ -243,18 +198,6 @@ def __init__(self, message: str, status_code: int) -> None: self.status_code = status_code -class PreSubmissionCheckerFailed(TaskServiceError): - """Raised when the locked pre-submit checker blocks submission creation.""" - - status_code = 422 - code = "pre_submission_checker_failed" - - def __init__(self, details: dict) -> None: - """Create a pre-submit failure carrying structured checker feedback.""" - super().__init__(self.code) - self.details = details - - class TaskLockedContextInvalid(TaskServiceError): """Raised when a task's stamped policy provenance is missing or inconsistent.""" @@ -300,8 +243,6 @@ def __init__(self, session: AsyncSession) -> None: self._session = session self._repo = TaskRepository(session) self._project_repo = ProjectRepository(session) - self._actors = ActorService(session) - self._legacy_workflow_eligibility = LegacyWorkflowEligibilityCompatibility(session) async def create_task( self, @@ -381,39 +322,6 @@ async def get_task(self, actor: ActorContext, task_id: str) -> TaskResponse: await self._ensure_task_visible(actor, task) return self._task_response(actor, task) - async def get_task_work_context( - self, - actor: ActorContext, - task_id: str, - ) -> TaskWorkContextResponse: - """Return Contributor-safe work context from the task's locked provenance. - - Args: - actor: Verified Flow actor context for the current request. - task_id: Task whose context should be returned. - - Returns: - Contributor-facing task, guide, policy, and lifecycle context. - - Raises: - PermissionDenied: If the actor cannot view tasks. - TaskNotFound: If the task is unknown or hidden. - TaskLockedContextInvalid: If locked context is incomplete or stale. - """ - require_any_role(actor, TASK_VIEW_ROLES) - task = await self._get_task(task_id) - await self._ensure_task_visible(actor, task) - context = await self._load_locked_task_context(task) - eligibility = await self._legacy_workflow_eligibility.get_active_submitter_eligibility( - actor.actor_id - ) - return self._work_context_response( - actor, - task, - context, - has_active_submitter_eligibility=("worker" in actor.roles and eligibility is not None), - ) - async def get_task_submission_requirements( self, actor: ActorContext, @@ -550,249 +458,6 @@ async def release_to_ready( await self._session.refresh(task) return self._task_response(actor, task) - async def claim_task( - self, - actor: ActorContext, - task_id: str, - reason: str | None = None, - ) -> TaskWithAssignmentResponse: - """Claim a ready task for the current actor. - - Args: - actor: Verified Flow actor context for the current request. - task_id: Ready task to claim. - reason: Optional transition reason stored in audit. - - Returns: - Task and active assignment response. - - Raises: - PermissionDenied: If the actor cannot claim tasks. - TaskAssignmentConflict: If an active assignment already exists. - """ - require_any_role(actor, TASK_CLAIM_ROLES) - await self._require_active_contributor(actor) - task = await self._get_task(task_id, for_update=True) - self._ensure_transition_allowed(task.status, TASK_STATUS_CLAIMED) - await self._require_legacy_submitter_eligibility(actor) - if await self._repo.get_active_assignment(task_id, for_update=True) is not None: - raise TaskAssignmentConflict("task already has an active assignment") - - assignment = TaskAssignment( - id=str(uuid4()), - task_id=task.id, - contributor_id=actor.actor_id, - assigned_by=actor.actor_id, - accepted_at=datetime.now(UTC), - status="active", - ) - try: - assignment = await self._repo.add_assignment(assignment) - task.assigned_to = actor.actor_id - await self._change_task_status( - actor, - task, - TASK_STATUS_CLAIMED, - reason, - event_payload={ - "assignment_id": assignment.id, - "contributor_id": assignment.contributor_id, - }, - ) - await self._session.commit() - except IntegrityError as exc: - await self._session.rollback() - raise TaskAssignmentConflict("task already has an active assignment") from exc - await self._session.refresh(task) - await self._session.refresh(assignment) - return TaskWithAssignmentResponse( - task=self._task_response(actor, task), - assignment=AssignmentResponse.model_validate(assignment), - ) - - async def start_task( - self, - actor: ActorContext, - task_id: str, - reason: str | None = None, - ) -> TaskResponse: - """Move a claimed task into active work. - - Args: - actor: Verified Flow actor context for the current request. - task_id: Claimed task to start. - reason: Optional transition reason stored in audit. - - Returns: - Updated task response. - - Raises: - PermissionDenied: If the actor cannot start this task. - TaskTransitionBlocked: If no active assignment exists. - """ - require_any_role(actor, TASK_START_ROLES) - task = await self._get_task(task_id) - self._ensure_transition_allowed(task.status, TASK_STATUS_IN_PROGRESS) - assignment = await self._repo.get_active_assignment(task_id) - if assignment is None: - raise TaskTransitionBlocked("task has no active assignment") - is_operator_override = assignment.contributor_id != actor.actor_id and bool( - set(actor.roles).intersection(TASK_START_OPERATOR_ROLES) - ) - if assignment.contributor_id != actor.actor_id and not is_operator_override: - raise TaskTransitionBlocked("actor is not assigned to this task") - if not is_operator_override: - await self._require_legacy_submitter_eligibility(actor) - if is_operator_override and (reason is None or not reason.strip()): - raise TaskValidationError("operator start override reason is required") - await self._change_task_status( - actor, - task, - TASK_STATUS_IN_PROGRESS, - reason, - event_payload={ - "assignment_id": assignment.id, - "contributor_id": assignment.contributor_id, - "operator_override": bool(is_operator_override), - }, - event_type="task_start_override" if is_operator_override else "task_status_changed", - ) - await self._session.commit() - await self._session.refresh(task) - return self._task_response(actor, task) - - async def create_submission( - self, - actor: ActorContext, - task_id: str, - payload: SubmissionCreate, - ) -> SubmissionResponse: - """Create a task-owned submission packet version. - - Args: - actor: Verified Flow actor context for the current request. - task_id: Task receiving the submission packet. - payload: Submission packet fields supplied by the Contributor. - - Returns: - Created submission response with evidence items. - - Raises: - PermissionDenied: If the actor cannot create Contributor submissions. - TaskProjectNotReady: If locked project policy context is invalid. - TaskTransitionBlocked: If task state or assignment does not allow submission. - TaskValidationError: If required submission fields are missing. - SubmissionVersionConflict: If concurrent version allocation conflicts. - """ - require_any_role(actor, TASK_SUBMIT_ROLES) - await self._require_active_contributor(actor) - task = await self._get_task(task_id, for_update=True) - if "worker" in actor.roles and task.assigned_to not in {None, actor.actor_id}: - raise TaskNotFound("task not found") - await self._require_legacy_submitter_eligibility(actor) - assignment = await self._repo.get_active_assignment(task_id, for_update=True) - if assignment is None: - raise TaskTransitionBlocked("task has no active assignment") - if assignment.contributor_id != actor.actor_id or task.assigned_to != actor.actor_id: - raise TaskNotFound("task not found") - if task.status not in { - TASK_STATUS_IN_PROGRESS, - TASK_STATUS_NEEDS_REVISION, - }: - raise TaskTransitionBlocked( - "task must be in progress or needs revision before submission" - ) - self._ensure_locked_context(task) - await self._load_locked_task_context(task) - - try: - pre_submit_response = await CheckerService(self._session).pre_submit_check( - actor, - task_id, - payload, - ) - except CheckerServiceError as exc: - raise SubmissionCheckerGateError(str(exc), exc.status_code) from exc - if not pre_submit_response.eligible_to_submit: - pre_submit_details = pre_submit_response.model_dump(mode="json") - await self._write_task_audit( - actor, - task, - event_type="pre_submission_check_failed", - from_status=task.status, - to_status=task.status, - reason=None, - event_payload={"pre_submit_check": pre_submit_details}, - ) - await self._session.commit() - raise PreSubmissionCheckerFailed(pre_submit_details) - - latest_submission = await self._repo.get_latest_submission_for_task(task.id) - next_version = 1 if latest_submission is None else latest_submission.version + 1 - submission = build_submission( - submission_id=str(uuid4()), task=task, contributor_id=actor.actor_id, - version=next_version, summary=payload.summary, - package_uri=payload.package_uri, package_hash=payload.package_hash, - artifact_hash_manifest=[ - entry.model_dump(mode="json") for entry in payload.artifact_hash_manifest - ], - worker_attestation=payload.worker_attestation, - supersedes_submission_id=None if latest_submission is None else latest_submission.id, - evidence_items=[ - EvidenceItem( - id=str(uuid4()), - type=evidence.type, - label=evidence.label, - uri=evidence.uri, - hash=evidence.hash, - size_bytes=evidence.size_bytes, - metadata_json=evidence.metadata, - ) - for evidence in payload.evidence_items - ], - ) - try: - submission = await self._repo.add_submission(submission) - event_payload = self._submission_audit_payload(submission) - if task.status in {TASK_STATUS_IN_PROGRESS, TASK_STATUS_NEEDS_REVISION}: - await self._change_task_status( - actor, - task, - TASK_STATUS_SUBMITTED, - reason=None, - event_payload=event_payload, - event_type="submission_created", - ) - else: - await self._write_task_audit( - actor, - task, - event_type="submission_created", - from_status=task.status, - to_status=task.status, - reason=None, - event_payload=event_payload, - ) - await self._finalize_submission_for_evaluation(actor, task, submission) - await self._session.commit() - except IntegrityError as exc: - await self._session.rollback() - raise SubmissionVersionConflict("submission version conflicted; retry") from exc - - await self._enqueue_pre_review_gate_after_commit( - actor, - submission.id, - raise_on_failure=False, - ) - persisted = await self._repo.get_submission(submission.id) - if persisted is None: - raise SubmissionNotFound("submission not found") - return self._submission_response( - actor, - persisted, - has_operator_access=can_admin_or_task_creator_manage(actor, task), - ) - async def list_task_submissions( self, actor: ActorContext, @@ -1170,17 +835,6 @@ async def _get_task( raise TaskNotFound("task not found") return task - async def _require_active_contributor(self, actor: ActorContext) -> None: - """Revalidate the exact canonical caller before contributor writes.""" - try: - await self._actors.require_active_human_write_actor(actor) - except ActiveHumanWriteActorRequired as exc: - await self._session.rollback() - raise ActiveContributorRequired(ActiveContributorRequired.message) from exc - except (CanonicalWriteActorUnavailable, SQLAlchemyError) as exc: - await self._session.rollback() - raise ContributorIdentityUnavailable(ContributorIdentityUnavailable.message) from exc - @staticmethod def _submission_audit_payload(submission: Submission) -> dict: """Build the task audit payload for a submission event. @@ -1544,11 +1198,10 @@ def _missing_locked_context_fields(self, task: WorkstreamTask) -> list[str]: def _work_context_response( self, - actor: ActorContext, task: WorkstreamTask, context: LockedTaskContext, *, - has_active_submitter_eligibility: bool, + lifecycle: TaskWorkerLifecycleContext, ) -> TaskWorkContextResponse: """Build the Contributor-safe work-context response.""" return TaskWorkContextResponse( @@ -1581,11 +1234,7 @@ def _work_context_response( currency=task.currency, payout_type=task.payout_type, ), - lifecycle=self._worker_lifecycle_context( - actor, - task, - has_active_submitter_eligibility=has_active_submitter_eligibility, - ), + lifecycle=lifecycle, ) def _submission_requirements_response( @@ -1916,47 +1565,6 @@ def _worker_safe_task_response(self, task: WorkstreamTask) -> TaskWorkerTaskCont updated_at=task.updated_at, ) - def _worker_lifecycle_context( - self, - actor: ActorContext, - task: WorkstreamTask, - *, - has_active_submitter_eligibility: bool, - ) -> TaskWorkerLifecycleContext: - """Build Contributor-facing lifecycle booleans and next actions.""" - assigned_to_current_actor = task.assigned_to == actor.actor_id - can_submit = ( - has_active_submitter_eligibility - and assigned_to_current_actor - and task.status - in { - TASK_STATUS_IN_PROGRESS, - TASK_STATUS_NEEDS_REVISION, - } - ) - next_actions: list[str] = [] - if ( - has_active_submitter_eligibility - and task.status == TASK_STATUS_READY - and task.assigned_to is None - ): - next_actions.append("claim") - elif ( - has_active_submitter_eligibility - and task.status == TASK_STATUS_CLAIMED - and assigned_to_current_actor - ): - next_actions.append("start") - elif can_submit: - next_actions.extend(["run_pre_submit_check", "submit"]) - return TaskWorkerLifecycleContext( - status=task.status, - assigned_to_current_actor=assigned_to_current_actor, - can_run_pre_submit_check=can_submit, - can_submit=can_submit, - next_actions=next_actions, - ) - def _validate_task_contract_fields(self, task: WorkstreamTask) -> None: """Validate task source and reviewability fields before screening. @@ -2099,30 +1707,6 @@ async def _validate_locked_post_submit_policy_context(self, task: WorkstreamTask except ValueError as exc: raise TaskProjectNotReady("locked post-submit checker policy hash is invalid") from exc - async def _require_legacy_submitter_eligibility( - self, - actor: ActorContext, - ) -> LegacyWorkflowEligibility: - """Require temporary active submitter eligibility for intake workflows. - - Args: - actor: Verified Flow actor context. - - Returns: - Persisted active legacy submitter eligibility. - - Raises: - LegacySubmitterEligibilityRequired: If eligibility is absent or inactive. - """ - profile = await self._legacy_workflow_eligibility.get_active_submitter_eligibility( - actor.actor_id - ) - if profile is None: - raise LegacySubmitterEligibilityRequired( - "active legacy submitter eligibility is required" - ) - return profile - async def _change_task_status( self, actor: ActorContext, @@ -2253,8 +1837,13 @@ def _task_response(self, actor: ActorContext, task: WorkstreamTask) -> TaskRespo Returns: Task response with internal locked policy hashes hidden from workers. """ + return self.task_response_for_authority(task, can_manage=can_admin_or_task_creator_manage(actor, task)) + + @staticmethod + def task_response_for_authority(task: WorkstreamTask, *, can_manage: bool) -> TaskResponse: + """Use an explicit authorized projection, never inferred token roles.""" response = TaskResponse.model_validate(task) - if not can_admin_or_task_creator_manage(actor, task): + if not can_manage: response.source_ref = None response.source_payload_hash = None response.import_batch_id = None diff --git a/backend/app/modules/tasks/submission_composition.py b/backend/app/modules/tasks/submission_composition.py index bb0189d02..08e6445e0 100644 --- a/backend/app/modules/tasks/submission_composition.py +++ b/backend/app/modules/tasks/submission_composition.py @@ -21,6 +21,7 @@ ) from app.modules.tasks.models import EvidenceItem, Submission from app.modules.tasks.repository import TaskRepository +from app.modules.tasks.service import TaskService def build_submission( @@ -92,6 +93,7 @@ def __init__( self._authorization = authorization self._admissions = admissions self._repository = TaskRepository(session) + self._contexts = TaskService(session) async def create(self, request: SubmissionCreationRequest) -> SubmissionCreationResult: """Create one Submission without opening or committing a transaction.""" @@ -116,6 +118,7 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation task = await self._repository.get_task(str(request.task_id)) if task is None: raise RuntimeError("locked task disappeared") + await self._contexts._load_locked_task_context(task) version = 1 if context.predecessor is None else context.predecessor.version + 1 submission_id = uuid4() submission = build_submission( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 204018b83..f544f1f6e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -49,6 +49,10 @@ markers = [ pythonpath = ["."] testpaths = ["tests"] +[tool.coverage.run] +# SQLAlchemy async I/O switches greenlets within the request thread. +concurrency = ["thread", "greenlet"] + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 396adc5be..063f7b9c2 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -135,13 +135,6 @@ async def seed_active_guide_for_pre_12h_e2e( NONLOCAL_DATABASE_OVERRIDE_VALUE = "I_UNDERSTAND_THIS_WRITES_DATA" TEST_MINIO_ACCESS_KEY = "workstream-minio" TEST_MINIO_SECRET_KEY = "workstream-minio-secret-key" -STRONG_ATTESTATION = ( - "I attest this submission contains no confidential client data, credentials, " - "secrets, tokens, passwords, API keys, private source material, source code, " - "copied platform artifacts, or copied platform content, and it satisfies " - "the original_work, credentials_and_secret_exclusion, real_api_originality, and " - "human_accountability_for_agent_assisted_work policy terms." -) def base64url_json(payload: dict) -> str: @@ -586,70 +579,6 @@ async def provision_guide_artifact_pipeline_services( assert body["actor_status"] == "active" -async def wait_for_submission_checker_run( - client: httpx.AsyncClient, - manager_token: str, - submission_id: str, -) -> dict: - """Wait for exactly one automatic checker run after submission lock. - - Args: - client: Real HTTP client. - manager_token: Project manager Flow token. - submission_id: Locked submission id. - - Returns: - Completed checker run response. - """ - last_count = 0 - for _ in range(50): - runs = await request_json( - client, - "GET", - f"/api/v1/submissions/{submission_id}/checker-runs", - manager_token, - ) - ensure(isinstance(runs, list), "checker run list did not return a list") - last_count = len(runs) - if len(runs) == 1 and runs[0]["trigger_source"] == "submission_finalized": - run = await request_json( - client, - "GET", - f"/api/v1/checker-runs/{runs[0]['id']}", - manager_token, - ) - if run["status"] == "completed": - return run - await asyncio.sleep(0.2) - raise AssertionError(f"expected one automatic checker run, got {last_count}") - - -async def wait_for_task_status( - client: httpx.AsyncClient, - manager_token: str, - task_id: str, - expected_status: str, -) -> dict: - """Wait for a task to reach an expected status through the API. - - Args: - client: Real HTTP client. - manager_token: Project manager Flow token. - task_id: Task id to poll. - expected_status: Expected status token. - - Returns: - Task response at the expected status. - """ - task: dict | None = None - for _ in range(50): - task = await request_json(client, "GET", f"/api/v1/tasks/{task_id}", manager_token) - if task["status"] == expected_status: - return task - await asyncio.sleep(0.2) - raise AssertionError( - f"expected task status {expected_status}, got {task['status'] if task else None}" - ) def ensure(condition: bool, message: str) -> None: @@ -663,34 +592,6 @@ def ensure(condition: bool, message: str) -> None: raise AssertionError(message) -def assert_checker_run_result_integrity(checker_run: dict, expected_names: set[str]) -> None: - """Assert checker result uniqueness and counters through API-visible data. - - Args: - checker_run: Checker run response returned by the HTTP API. - expected_names: Exact checker names required for the run. - """ - results = checker_run["results"] - names = [result["checker_name"] for result in results] - ensure(set(names) == expected_names, f"checker set drifted: {set(names)}") - ensure(len(names) == len(set(names)), f"duplicate checker results returned: {names}") - ensure( - checker_run["warning_count"] - == sum(1 for result in results if result["status"] == "warning"), - "checker warning count does not match returned results", - ) - ensure( - checker_run["failed_count"] == sum(1 for result in results if result["status"] == "failed"), - "checker failed count does not match returned results", - ) - ensure( - checker_run["blocking_count"] == sum(1 for result in results if result["blocks_review"]), - "checker blocking count does not match returned results", - ) - ensure( - checker_run["passed_count"] == sum(1 for result in results if result["status"] == "passed"), - "checker passed count does not match returned results", - ) def assert_local_database_url(database_url: str) -> None: @@ -2075,17 +1976,6 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert updated_actor["display_name"] == "Real API Contributor" assert updated_actor["admin_roles"] == [] assert updated_actor["project_role_grants"] == [] - worker_profile = await request_json( - client, - "POST", - "/api/v1/workers/me/profile", - worker_token, - {"skill_tags": ["stem", "proofs"]}, - ) - assert worker_profile["external_subject"] == worker_subject - assert worker_profile["external_issuer"] == flow_issuer - assert worker_profile["status"] == "active" - assert set(worker_profile["skill_tags"]) == {"stem", "proofs"} role_issue_key = str(uuid4()) role_issue_body = { "target_actor_profile_id": canonical_actor["actor_profile_id"], @@ -2252,6 +2142,18 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: json={"reason": "Restore API contract contributor identity link"}, ) assert repaired_target_link.status_code == 200, repaired_target_link.text + await request_json( + client, "POST", f"/api/v1/tasks/{task['id']}/claim", worker_token, + {"reason": "Revoked authority must not permit task work"}, 403, + ) + renewed_submitter = await client.post( + f"/api/v1/projects/{project['id']}/role-grants", + headers=auth_headers(project_reader_token) | {"Idempotency-Key": str(uuid4())}, + json=role_issue_body | {"reason": "Authorize the subsequent task claim/start drill"}, + ) + assert renewed_submitter.status_code == 201, renewed_submitter.text + assert renewed_submitter.json()["id"] != role_grant_id + assert renewed_submitter.json()["status"] == "active" removed_project_manager = await client.post( f"/api/v1/admin-role-grants/{project_manager_grant.json()['resource_id']}/revoke", headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, @@ -2394,159 +2296,45 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: worker_token, ) ensure( - active_work_context["lifecycle"]["can_submit"] is True, - "in-progress worker context did not expose submit readiness", - ) - submission = await request_json( - client, - "POST", - f"/api/v1/tasks/{task['id']}/submissions", - worker_token, - { - "summary": "Real API packet completed.", - "package_uri": f"local://packages/token=build-{run_id}.tar.zst", - "package_hash": f"sha256:package-{run_id}", - "artifact_hash_manifest": [ - { - "artifact": "answer.md", - "hash": f"sha256:answer-{run_id}", - "size_bytes": 128, - "notes": "real API artifact", - } - ], - "worker_attestation": STRONG_ATTESTATION, - "evidence_items": [ - { - "type": "log", - "label": "real API evidence", - "uri": f"s3://workstream-e2e/reports/user@team-{run_id}.log", - "hash": f"sha256:evidence-{run_id}", - "size_bytes": 256, - "metadata": { - "command": "api_contract_e2e", - "required_evidence_key": "checker_log", - }, - } - ], - }, - 201, + active_work_context["lifecycle"]["can_submit"] is False, + "hidden submission creation must not be advertised as a public action", ) ensure( - submission["contributor_id"] == canonical_actor["actor_profile_id"], - "submission did not return canonical contributor attribution", - ) - for internal_field in ( - "artifact_hash_manifest", - "package_hash", - "worker_attestation", - "locked_guide_version", - "locked_review_policy_id", - "locked_review_policy_generation", - "locked_review_policy_hash", - "locked_revision_policy_id", - "locked_revision_policy_generation", - "locked_revision_policy_hash", - "locked_payment_policy_version", - "locked_post_submit_checker_policy_hash", - ): - assert internal_field not in submission - await request_json( - client, - "POST", - f"/api/v1/tasks/{task['id']}/submissions", - manager_token, - { - "summary": "Manager cannot submit for worker.", - "package_hash": f"sha256:manager-package-{run_id}", - "artifact_hash_manifest": [ - {"artifact": "answer.md", "hash": f"sha256:manager-answer-{run_id}"} - ], - "worker_attestation": STRONG_ATTESTATION, - "evidence_items": [], - }, - 403, + active_work_context["lifecycle"]["can_run_pre_submit_check"] is False, + "the current command surface must not advertise hidden intake", ) - await request_json(client, "GET", f"/api/v1/tasks/{task['id']}/submissions", worker_token) - await request_json(client, "GET", f"/api/v1/submissions/{submission['id']}", worker_token) - await request_json( - client, - "GET", - f"/api/v1/submissions/{submission['id']}", - unassigned_worker_token, - expected_status=404, + ensure( + active_work_context["lifecycle"]["next_actions"] == [], + "in-progress context advertised an unavailable public command", ) - locked = await request_json( - client, - "GET", - f"/api/v1/submissions/{submission['id']}", - manager_token, + # Public packet submission was retired. Do not simulate its success or + # expose the hidden admission-backed command to keep this drill running. + submissions = await request_json( + client, "GET", f"/api/v1/tasks/{task['id']}/submissions", worker_token, ) - assert locked["finalized_at"] is not None - assert locked["locked_guide_version"] == "v1" - assert locked["locked_review_policy_id"] == screened["locked_review_policy_id"] - assert locked["locked_review_policy_generation"] == 1 - assert locked["locked_review_policy_hash"] == screened["locked_review_policy_hash"] - assert locked["locked_revision_policy_id"] == screened["locked_revision_policy_id"] - assert locked["locked_revision_policy_generation"] == 1 - assert locked["locked_revision_policy_hash"] == screened["locked_revision_policy_hash"] - assert locked["locked_payment_policy_version"] == "v1" - assert all( - item["finalized_at"] == locked["finalized_at"] for item in locked["evidence_items"] - ) - checker_run = await wait_for_submission_checker_run(client, manager_token, submission["id"]) - assert checker_run["routing_recommendation"] == "allow_review" - assert checker_run["triggered_by"] == "workstream-system:pre-review-gate" - assert checker_run["triggered_by_subject"] == "workstream-system:pre-review-gate" - assert checker_run["triggered_by_issuer"] == "workstream" - assert checker_run["trigger_auth_source"] == "workstream_system" - assert_checker_run_result_integrity(checker_run, EXPECTED_DURABLE_CHECKERS) - await wait_for_task_status(client, manager_token, task["id"], "review_pending") + ensure(submissions == [], "claim/start unexpectedly created a Submission") audit_events = await request_json( - client, - "GET", - f"/api/v1/tasks/{task['id']}/audit-events", - manager_token, + client, "GET", f"/api/v1/tasks/{task['id']}/audit-events", manager_token, ) audit_transitions = { (event["event_type"], event["from_status"], event["to_status"]) for event in audit_events } - for expected_transition in { + assert audit_transitions == { ("task_created", None, "draft"), ("task_status_changed", "draft", "screening"), ("task_status_changed", "screening", "ready"), - ("task_status_changed", "ready", "claimed"), - ("task_status_changed", "claimed", "in_progress"), - ("submission_created", "in_progress", "submitted"), - ("submission_finalized", "submitted", "submitted"), - ("pre_review_gate_started", "submitted", "evaluation_pending"), - ("pre_review_gate_passed", "evaluation_pending", "review_pending"), - }: - assert expected_transition in audit_transitions - finalized_event = next( - event for event in audit_events if event["event_type"] == "submission_finalized" - ) - assert finalized_event["external_subject"] == worker_subject - assert finalized_event["external_issuer"] == flow_issuer - assert finalized_event["auth_source"] == "flow" - assert ( - finalized_event["event_payload"]["finalized_at"].replace("+00:00", "Z") - == locked["finalized_at"] - ) - requester_actor_id = finalized_event["actor_id"] - assert requester_actor_id - assert requester_actor_id != "workstream-system:pre-review-gate" - for event_type in ("pre_review_gate_started", "pre_review_gate_passed"): - gate_event = next(event for event in audit_events if event["event_type"] == event_type) - assert gate_event["actor_id"] == "workstream-system:pre-review-gate" - assert gate_event["external_subject"] == "workstream-system:pre-review-gate" - assert gate_event["external_issuer"] == "workstream" - assert gate_event["auth_source"] == "workstream_system" - assert gate_event["event_payload"]["requester_actor_id"] == requester_actor_id - assert gate_event["event_payload"]["requester_external_subject"] == worker_subject - assert gate_event["event_payload"]["requester_external_issuer"] == flow_issuer - assert gate_event["event_payload"]["requester_auth_source"] == "flow" - assert gate_event["event_payload"]["trigger_source"] == "submission_finalized" + ("TaskClaimed", "ready", "claimed"), + ("TaskStarted", "claimed", "in_progress"), + } + for event in audit_events: + if event["event_type"] in {"TaskClaimed", "TaskStarted"}: + assert event["actor_id"] == canonical_actor["actor_profile_id"] + assert event["actor_roles"] == [] and event["claim_snapshot"] == {} + references = event["event_payload"]["references"] + assert references["task_id"] == task["id"] + assert references["assignment_id"] == claim["assignment"]["id"] + assert references["authorization_decision_id"] worker_audit_events = await request_json( client, "GET", @@ -2565,13 +2353,11 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: expected_status=403, ) - print("API contract real API e2e passed") + print("Public API drill passed through authorized task claim/start; hidden submission not exercised") print(f"project_id={project['id']}") print(f"guide_id={guide['id']}") print(f"task_id={task['id']}") print(f"assignment_id={claim['assignment']['id']}") - print(f"submission_id={submission['id']}") - print(f"submission_finalized_at={locked['finalized_at']}") async def main(env: dict[str, str]) -> None: diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 4b63a4292..acdc0a84f 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -310,6 +310,15 @@ "backend/scripts/schema_baseline_sql.py", } ) +TASK_PROJECT_AUTHORITY_TARGETS = frozenset( + { + "backend/app/modules/authorization/domain/task_authority.py", + "backend/app/modules/authorization/task_authorization.py", + "backend/app/modules/tasks/api/authorization.py", + "backend/app/modules/tasks/api/transition_audit.py", + "backend/app/modules/tasks/authorized_commands.py", + } +) class BehaviorOwnershipError(RuntimeError): @@ -485,6 +494,7 @@ def _validate_additive_partition_transition( | ARCH_04A_POST_SUBMIT_TARGETS | ARCH_CP05_POLICY_AUTH_TARGETS | V01_BASELINE_ADDED_TARGETS + | TASK_PROJECT_AUTHORITY_TARGETS ) expected_additions = (approved_additions & additions) - set(trusted_targets) if POL_03A_DECLARATIVE_MODEL_TARGET in additions: diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 94b927427..8da3a16a2 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -119,7 +119,16 @@ class TestLane: "tests/actors/test_self_api.py", "tests/actors/test_self_api_lifecycle.py", "tests/actors/test_identity_bounds_and_rate_controls.py", - "tests/actors/test_legacy_eligibility_postgresql.py", + "tests/authorization/task_authority/test_audit_contract.py", + "tests/authorization/task_authority/test_concurrency.py", + "tests/authorization/task_authority/test_postgresql.py", + "tests/authorization/task_authority/test_prepared.py", + "tests/authorization/task_authority/test_public_surface.py", + "tests/authorization/task_authority/test_submission_policy.py", + "tests/authorization/task_authority/test_submission_authority.py", + "tests/authorization/task_authority/test_shared_project_authority.py", + "tests/authorization/task_authority/test_lifecycle_races.py", + "tests/authorization/task_authority/test_task_commands.py", "tests/actors/test_compensation_adapter_eligibility.py", "tests/test_api_rate_controls.py", "tests/test_audit.py", @@ -272,6 +281,8 @@ class TestLane: "tests/checkers/post_submit/test_result_contract.py", "tests/test_checker_catalogue.py", "tests/test_checkers.py", + "tests/checkers/test_packet_schema.py", + "tests/checkers/test_effective_intake_rules.py", "tests/test_default_pre_submit_execution.py", "tests/test_effective_pre_submit_execution.py", "tests/test_project_guide_compilation_contracts.py", diff --git a/backend/tests/actors/test_authorization_locks.py b/backend/tests/actors/test_authorization_locks.py index 55b6fc33a..bb83fe055 100644 --- a/backend/tests/actors/test_authorization_locks.py +++ b/backend/tests/actors/test_authorization_locks.py @@ -4,12 +4,8 @@ import pytest -from app.modules.actors.service import ( - ActorService, - ActiveHumanWriteActorRequired, - CanonicalWriteActorUnavailable, -) -from tests.actors.support import ISSUER, legacy_actor, resolved_actor +from app.modules.actors.service import ActorService +from tests.actors.support import resolved_actor class LockedRows: @@ -38,11 +34,6 @@ async def get_identity_link_by_id(self, link_id, *, for_update=False): assert (link_id, for_update) == (self.expected_link_id, True) return self.link - async def get_identity_link(self, issuer, subject, *, for_update=False): - self.calls.append(("identity", issuer, subject, for_update)) - assert (issuer, subject, for_update) == (ISSUER, self.expected_subject, True) - return self.link - def controlled_service(original): repository = LockedRows(original) @@ -105,74 +96,3 @@ async def test_actor_authorization_lock_returns_exact_locked_rows(): ("profile", original.profile.id, True), ("link", original.identity_link.id, True), ] - - -async def test_active_human_write_actor_revalidates_exact_profile_then_link(): - actor = legacy_actor("contributor-write") - original = resolved_actor(actor_id=actor.actor_id, subject=actor.external_subject) - service, repository = controlled_service(original) - - assert await service.require_active_human_write_actor(actor) is None - - assert repository.calls == [ - ("profile", actor.actor_id, True), - ("identity", actor.external_issuer, actor.external_subject, True), - ] - - -@pytest.mark.parametrize( - ("failure", "message"), - [ - ("missing-profile", "profile is missing"), - ("missing-link", "link is missing"), - ("wrong-owner", "link is inconsistent"), - ("nonhuman-link", "link is inconsistent"), - ], -) -async def test_active_human_write_actor_rejects_unavailable_rows(failure, message): - actor = legacy_actor("contributor-write") - service, repository = controlled_service( - resolved_actor(actor_id=actor.actor_id, subject=actor.external_subject) - ) - if failure == "missing-profile": - repository.profile = None - elif failure == "missing-link": - repository.link = None - elif failure == "nonhuman-link": - repository.link.subject_kind = "service" - else: - repository.link.actor_profile_id = str(uuid4()) - - with pytest.raises(CanonicalWriteActorUnavailable, match=message): - await service.require_active_human_write_actor(actor) - - expected = [("profile", actor.actor_id, True)] - if failure != "missing-profile": - expected.append(("identity", actor.external_issuer, actor.external_subject, True)) - assert repository.calls == expected - - -@pytest.mark.parametrize( - ("row", "field", "value"), - [ - ("profile", "actor_kind", "service"), - ("profile", "status", "suspended"), - ("profile", "status", "deactivated"), - ("link", "status", "revoked"), - ], - ids=["service-profile", "suspended", "deactivated", "revoked-link"], -) -async def test_active_human_write_actor_rejects_ineligible_identity(row, field, value): - actor = legacy_actor("contributor-write") - service, repository = controlled_service( - resolved_actor(actor_id=actor.actor_id, subject=actor.external_subject) - ) - setattr(getattr(repository, row), field, value) - - with pytest.raises(ActiveHumanWriteActorRequired, match="active contributor identity required"): - await service.require_active_human_write_actor(actor) - - expected = [("profile", actor.actor_id, True)] - if row == "link": - expected.append(("identity", actor.external_issuer, actor.external_subject, True)) - assert repository.calls == expected diff --git a/backend/tests/actors/test_identity_bounds_and_rate_controls.py b/backend/tests/actors/test_identity_bounds_and_rate_controls.py index 5eaa52477..2652d3277 100644 --- a/backend/tests/actors/test_identity_bounds_and_rate_controls.py +++ b/backend/tests/actors/test_identity_bounds_and_rate_controls.py @@ -44,27 +44,24 @@ async def test_actor_api_accepts_verifier_identity_bounds( assert link is not None -async def test_legacy_provenance_accepts_verifier_identity_bounds(actor_client, monkeypatch): +async def test_identity_bounds_preserve_private_canonical_provisioning_evidence(actor_client, monkeypatch): issuer = ("https://identity.test/" + "i" * 200)[:200] subject = "s" * 200 set_dev_actor(monkeypatch, roles="worker", subject=subject, issuer=issuer) created = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) assert created.status_code == 200 - eligibility = await actor_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": ["stem"]}, - ) - assert eligibility.status_code == 200, eligibility.text async with db_session.get_session_factory()() as session: event = await session.scalar( select(AuditEvent).where( - AuditEvent.event_type == "legacy_workflow_eligibility_activated" + AuditEvent.event_type == "ActorProfileProvisioned", + AuditEvent.actor_id == created.json()["actor_profile_id"], ) ) assert event is not None - assert event.external_issuer == issuer - assert event.external_subject == subject + assert event.external_issuer is None + assert event.external_subject is None + assert event.actor_roles == [] + assert event.claim_snapshot == {} @pytest.mark.parametrize("field", ["issuer", "subject"]) diff --git a/backend/tests/actors/test_legacy_eligibility_postgresql.py b/backend/tests/actors/test_legacy_eligibility_postgresql.py deleted file mode 100644 index d750036de..000000000 --- a/backend/tests/actors/test_legacy_eligibility_postgresql.py +++ /dev/null @@ -1,238 +0,0 @@ -# pyright: reportArgumentType=false, reportAttributeAccessIssue=false -# pyright: reportOptionalMemberAccess=false, reportOptionalOperand=false -from __future__ import annotations - -import asyncio -from uuid import uuid4 - -import pytest -from sqlalchemy import func, select, text - -from app.db import session as db_session -from app.modules.actors.models import ( - LegacyActorIdentity, - LegacyWorkflowEligibility, -) -from app.modules.actors.repository import ActorRepository -from app.modules.actors.schemas import ( - LegacyWorkflowEligibilityActivationRequest, -) -from app.modules.actors.service import ( - ActorProfileDisabled, - ActorService, - LegacyWorkflowEligibilityCompatibility, -) -from app.modules.tasks.models import AuditEvent - -from tests.actors.support import verified_token, legacy_actor -from auth_concurrency_support import wait_for_named_database_lock - - -async def test_legacy_activation_writes_only_compatibility_metadata( - actor_database_env: str, -) -> None: - actor = legacy_actor("legacy-intake") - async with db_session.get_session_factory()() as session: - await ActorService(session).resolve_verified_actor( - verified_token("legacy-intake"), - request_id=uuid4(), - correlation_id=uuid4(), - ) - response = await ActorService(session).activate_legacy_workflow_eligibility( - actor, - LegacyWorkflowEligibilityActivationRequest(skill_tags=["STEM", "stem"]), - ) - assert response.status == "active" - assert response.skill_tags == ["stem"] - async with db_session.get_session_factory()() as session: - assert await session.get(LegacyActorIdentity, actor.actor_id) is not None - eligibility = await session.scalar( - select(LegacyWorkflowEligibility).where( - LegacyWorkflowEligibility.actor_id == actor.actor_id - ) - ) - assert eligibility is not None - assert eligibility.profile_metadata == {"source": "legacy_worker_profile_api"} - table_names = set( - await session.scalars( - text("select tablename from pg_tables where schemaname=current_schema()") - ) - ) - assert "admin_role_grants" in table_names - assert await session.scalar(text("select count(*) from admin_role_grants")) == 0 - assert "project_role_grants" in table_names - assert "project_role_qualification_snapshots" in table_names - assert await session.scalar(text("select count(*) from project_role_grants")) == 0 - assert ( - await session.scalar(text("select count(*) from project_role_qualification_snapshots")) - == 0 - ) - - -async def test_repeated_legacy_activation_updates_one_row_and_audits_only_changes( - actor_database_env: str, -) -> None: - actor = legacy_actor("legacy-repeat") - async with db_session.get_session_factory()() as session: - await ActorService(session).resolve_verified_actor( - verified_token("legacy-repeat"), - request_id=uuid4(), - correlation_id=uuid4(), - ) - service = ActorService(session) - first = await service.activate_legacy_workflow_eligibility( - actor, - LegacyWorkflowEligibilityActivationRequest(skill_tags=["stem"]), - ) - changed = await service.activate_legacy_workflow_eligibility( - actor, - LegacyWorkflowEligibilityActivationRequest(skill_tags=["data"]), - ) - unchanged = await service.activate_legacy_workflow_eligibility( - actor, - LegacyWorkflowEligibilityActivationRequest(skill_tags=["data"]), - ) - - assert first.id == changed.id == unchanged.id - assert first.skill_tags == ["stem"] - assert changed.skill_tags == unchanged.skill_tags == ["data"] - async with db_session.get_session_factory()() as session: - assert ( - await session.scalar( - select(func.count()) - .select_from(LegacyWorkflowEligibility) - .where(LegacyWorkflowEligibility.actor_id == actor.actor_id) - ) - == 1 - ) - assert ( - await session.scalar( - select(func.count()) - .select_from(AuditEvent) - .where( - AuditEvent.entity_type == "legacy_workflow_eligibility", - AuditEvent.actor_id == actor.actor_id, - ) - ) - == 2 - ) - - -async def test_concurrent_legacy_activation_serializes_payloads_and_actual_audits( - actor_database_env: str, -) -> None: - actor = legacy_actor("legacy-concurrent-activation") - async with db_session.get_session_factory()() as session: - await ActorService(session).resolve_verified_actor( - verified_token("legacy-concurrent-activation"), - request_id=uuid4(), - correlation_id=uuid4(), - ) - - async with ( - db_session.get_session_factory()() as first_session, - db_session.get_session_factory()() as second_session, - ): - await ActorRepository(first_session).lock_external_identity( - actor.external_issuer, - actor.external_subject, - ) - blocker_pid = await first_session.scalar(text("select pg_backend_pid()")) - waiter_pid = await second_session.scalar(text("select pg_backend_pid()")) - assert blocker_pid != waiter_pid - waiter_name = f"actor-legacy-{uuid4().hex}" - await second_session.execute( - text("select set_config('application_name', :name, true)"), {"name": waiter_name} - ) - second_activation = asyncio.create_task( - ActorService(second_session).activate_legacy_workflow_eligibility( - actor, - LegacyWorkflowEligibilityActivationRequest(skill_tags=["second"]), - ) - ) - try: - await asyncio.wait_for( - wait_for_named_database_lock( - actor_database_env, - waiter_name, - expected_waiter_pid=waiter_pid, - expected_blocker_pid=blocker_pid, - ), - timeout=5, - ) - assert not second_activation.done() - first = await ActorService(first_session).activate_legacy_workflow_eligibility( - actor, - LegacyWorkflowEligibilityActivationRequest(skill_tags=["first"]), - ) - second = await asyncio.wait_for(second_activation, timeout=5) - except Exception: - if second_activation.done(): - await ( - second_activation - ) # Surface a worker failure instead of masking it as a wait failure. - raise - finally: - if not second_activation.done(): - second_activation.cancel() - await asyncio.gather(second_activation, return_exceptions=True) - - assert first.skill_tags == ["first"] - assert second.skill_tags == ["second"] - assert first.id == second.id - async with db_session.get_session_factory()() as session: - eligibility = await session.scalar( - select(LegacyWorkflowEligibility).where( - LegacyWorkflowEligibility.actor_id == actor.actor_id - ) - ) - assert eligibility is not None - assert eligibility.skill_tags == ["second"] - assert ( - await session.scalar( - select(func.count()) - .select_from(AuditEvent) - .where( - AuditEvent.entity_type == "legacy_workflow_eligibility", - AuditEvent.actor_id == actor.actor_id, - ) - ) - == 2 - ) - - -async def test_disabled_legacy_eligibility_is_not_reactivated(actor_database_env: str) -> None: - legacy = legacy_actor("disabled-legacy") - async with db_session.get_session_factory()() as session: - session.add_all( - [ - LegacyActorIdentity( - actor_id=legacy.actor_id, - external_subject=legacy.external_subject, - external_issuer=legacy.external_issuer, - last_seen_roles=["worker"], - last_claim_snapshot={}, - auth_source="dev_mock", - is_dev_auth=True, - ), - LegacyWorkflowEligibility( - id=str(uuid4()), - actor_id=legacy.actor_id, - profile_type="worker", - status="disabled", - skill_tags=[], - scope_type="global", - scope_id="global", - profile_metadata={}, - ), - ] - ) - await session.commit() - with pytest.raises(ActorProfileDisabled): - await ActorService(session).activate_legacy_workflow_eligibility( - legacy, - LegacyWorkflowEligibilityActivationRequest(skill_tags=[]), - ) - compatibility = LegacyWorkflowEligibilityCompatibility(session) - assert await compatibility.get_active_submitter_eligibility(legacy.actor_id) is None - assert await compatibility.get_active_submitter_eligibility(str(uuid4())) is None diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index bcb45c863..5f366ac3a 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -226,7 +226,11 @@ expected = { "actor.profile.read_self": ("actor.profile.read_self", "WS-AUTH-001-07B"), "actor.profile.update_self": ("actor.profile.update_self", "WS-AUTH-001-07B"), - "operations.task.start_override": ("operations.task.start_override", "WS-AUTH-001-13"), + "operations.task.start_override": ("operations.task.start_override", "task-project-grant-authorization"), + "task.claim": ("task.claim", "task-project-grant-authorization"), + "task.start": ("task.claim", "task-project-grant-authorization"), + "task.work_context.read": ("task.queue.read", "task-project-grant-authorization"), + "project.task.work_context.read": ("project.task.manage", "task-project-grant-authorization"), "operations.submission_gate.repair": ( "operations.submission_gate.repair", "WS-AUTH-001-14", @@ -363,6 +367,11 @@ AUDIT_ALLOWED_ACTION_VALUES = { + "task.claim", + "task.start", + "task.work_context.read", + "project.task.work_context.read", + "operations.task.start_override", "actor.admin_role_grant_history.read", "actor.authorization_context.read", "actor.identity_link.reactivate", diff --git a/backend/tests/authorization/setup_finalization/test_catalogue.py b/backend/tests/authorization/setup_finalization/test_catalogue.py index 49f613512..d83da698f 100644 --- a/backend/tests/authorization/setup_finalization/test_catalogue.py +++ b/backend/tests/authorization/setup_finalization/test_catalogue.py @@ -120,7 +120,7 @@ def test_setup_existing_active_pairs_and_downstream_plans_are_preserved(): def test_exact_active_action_inventory(): - """Preserve finalization and the complete catalogue after CP05 activation.""" + """Preserve finalization and the complete catalogue after TASK activation.""" from app.modules.authorization.catalogue import ACTION_DEFINITIONS assert { @@ -184,6 +184,11 @@ def test_exact_active_action_inventory(): ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE, + ActionId.TASK_CLAIM, + ActionId.TASK_START, + ActionId.TASK_WORK_CONTEXT_READ, + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, + ActionId.OPERATIONS_TASK_START_OVERRIDE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, ActionId.COMPENSATION_ADAPTER_BINDING_READ, ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, diff --git a/backend/tests/authorization/task_authority/__init__.py b/backend/tests/authorization/task_authority/__init__.py new file mode 100644 index 000000000..247cb2e05 --- /dev/null +++ b/backend/tests/authorization/task_authority/__init__.py @@ -0,0 +1 @@ +"""Canonical TASK authority proofs with package-scoped fixtures.""" diff --git a/backend/tests/authorization/task_authority/conftest.py b/backend/tests/authorization/task_authority/conftest.py new file mode 100644 index 000000000..73b635a33 --- /dev/null +++ b/backend/tests/authorization/task_authority/conftest.py @@ -0,0 +1,11 @@ +"""Reuse signed actor/bootstrap fixtures; database custody stays explicit.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) diff --git a/backend/tests/authorization/task_authority/test_audit_contract.py b/backend/tests/authorization/task_authority/test_audit_contract.py new file mode 100644 index 000000000..3b05112a5 --- /dev/null +++ b/backend/tests/authorization/task_authority/test_audit_contract.py @@ -0,0 +1,171 @@ +"""Task lifecycle evidence has closed transitions and exact decision references.""" + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from app.modules.audit.schemas import ( + ActorReferenceKind, AuthorityAuditEventInput, AuthorityEventType, + LifecycleAuditEntityType as Entity, + LifecycleAuditEventInput, + LifecycleAuditEventType as Event, + LifecycleAuditReason as Reason, + LifecycleAuditReferenceKind as Reference, +) +from app.modules.authorization.catalogue import ( + ACTION_DEFINITIONS, ActionAvailability, ActionId, PermissionId, +) +from tests.authorization.catalogue_fixtures import AUDIT_ALLOWED_ACTION_VALUES +from tests.test_audit import _authority_input + + +def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> None: + denied = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.binding.read", + action_id="artifact.binding.read", + denial_code="permission_not_granted", + ) + assert (denied.action_id, denied.permission_id) == (ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ) + with pytest.raises(ValidationError, match="action permission"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.replica.read", + action_id="artifact.binding.read", + denial_code="permission_not_granted", + ) + with pytest.raises(ValidationError, match="new permission requires"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.binding.read", + action_id=None, + denial_code="permission_not_granted", + ) + allowed_action_ids: set[ActionId] = set() + for definition in ACTION_DEFINITIONS: + if definition.availability is ActionAvailability.PLANNED: + with pytest.raises(ValidationError, match="planned action"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id=definition.permission_id, + action_id=definition.action_id, + ) + else: + allowed = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id=definition.permission_id, + action_id=definition.action_id, + ) + assert allowed.action_id is not None + allowed_action_ids.add(allowed.action_id) + assert {action.value for action in allowed_action_ids} == AUDIT_ALLOWED_ACTION_VALUES + artifact_allowed = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + after_facts={"allowed": True, "resource_context_digest": "sha256:" + "a" * 64}, + ) + assert artifact_allowed.after_facts["resource_context_digest"] == "sha256:" + "a" * 64 + with pytest.raises(TypeError, match="invalid authority audit input"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + after_facts={"allowed": True, "resource_context_digest": "not-a-digest"}, + ) + with pytest.raises(TypeError, match="invalid authority audit input"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.binding.read", + action_id="unknown.action", + denial_code="permission_not_granted", + ) + event_id = uuid4() + with pytest.raises(ValidationError, match="action requires authorization decision"): + AuthorityAuditEventInput( + event_id=event_id, + event_type=AuthorityEventType.ADMIN_ROLE_GRANT_ISSUE_DENIED, + entity_type="admin_role_grant", + entity_id=str(uuid4()), + actor_ref_kind=ActorReferenceKind.SYSTEM_PRINCIPAL, + actor_ref="workstream:system:bootstrap", + request_id=uuid4(), + correlation_id=uuid4(), + permission_id=PermissionId.ACTOR_PROFILE_READ_SELF, + action_id=ActionId.ACTOR_PROFILE_READ_SELF, + reason="authorization_policy_denial", + denial_code="permission_not_granted", + ) + + +def event_fields(): + task_id = uuid4() + return dict( + event_id=uuid4(), + entity_type=Entity.TASK, + entity_id=task_id, + event_type=Event.TASK_CLAIMED, + actor_id=uuid4(), + reason=Reason.STATE_CHANGED, + from_status="ready", + to_status="claimed", + references={ + Reference.TASK: task_id, + Reference.PROJECT: uuid4(), + Reference.ASSIGNMENT: uuid4(), + Reference.AUTHORIZATION_DECISION: uuid4(), + }, + ) + + +@pytest.mark.parametrize( + "event,before,after,reason", + [ + (Event.TASK_CLAIMED, "ready", "claimed", None), + (Event.TASK_STARTED, "claimed", "in_progress", "Begin assigned work"), + (Event.TASK_START_OVERRIDDEN, "claimed", "in_progress", "Authorized operator intervention"), + ], +) +def test_task_event_preserves_exact_transition_and_reason(event, before, after, reason): + value = LifecycleAuditEventInput( + **( + event_fields() + | dict( + event_type=event, + from_status=before, + to_status=after, + task_reason=reason, + ) + ) + ) + assert value.task_reason == reason + assert value.event_type is event + assert value.references[Reference.AUTHORIZATION_DECISION] + + +@pytest.mark.parametrize( + "change", + [ + {"to_status": "in_progress"}, + {"from_status": "claimed"}, + {"task_reason": " "}, + {"task_reason": "x" * 1001}, + { + "event_type": Event.TASK_START_OVERRIDDEN, + "from_status": "claimed", + "to_status": "in_progress", + }, + ], +) +def test_task_event_rejects_wrong_transition_or_unbounded_reason(change): + with pytest.raises(ValidationError): + LifecycleAuditEventInput(**(event_fields() | change)) + + +@pytest.mark.parametrize("reference", [Reference.AUTHORIZATION_DECISION, Reference.ASSIGNMENT]) +def test_task_event_requires_assignment_and_authorization_evidence(reference): + fields = event_fields() + fields["references"].pop(reference) + with pytest.raises(ValidationError, match="exact canonical references"): + LifecycleAuditEventInput(**fields) diff --git a/backend/tests/authorization/task_authority/test_concurrency.py b/backend/tests/authorization/task_authority/test_concurrency.py new file mode 100644 index 000000000..6b65b9571 --- /dev/null +++ b/backend/tests/authorization/task_authority/test_concurrency.py @@ -0,0 +1,206 @@ +"""Real command/database races, with barriers only at observed owner boundaries.""" + +import asyncio +from uuid import UUID, uuid4 + +import pytest +from auth_concurrency_support import wait_for_named_database_lock +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from app.db import session as db_session +from app.db.session import get_db_session +from app.adapters.audit import task_transition_audit +from app.modules.actors.models import ActorIdentityLink +from app.modules.authorization.runtime import ( + ActorKind, ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, +) +from app.modules.authorization.task_authorization import PreparedTaskAuthorization +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.models import ProjectRoleGrant +from app.modules.tasks.authorized_commands import AuthorizedTaskCommands +from app.modules.tasks.models import AuditEvent, TaskAssignment, WorkstreamTask +from app.modules.tasks.schemas import TaskWithAssignmentResponse +from app.modules.tasks.api.authorization import TaskAuthorityDenied +from app.modules.tasks.repository import TaskRepository +from tests.test_tasks import ( + task_database_env as task_database_env, + task_client as task_client, + create_active_project, create_ready_task, admit_and_grant_project_submitter, + set_dev_actor, auth_headers, +) + + +async def actor_context(actor_id): + async with db_session.get_session_factory()() as session: + link = await session.scalar(select(ActorIdentityLink).where( + ActorIdentityLink.actor_profile_id == actor_id, + )) + return HumanAuthorizationContext( + actor_profile_id=UUID(actor_id), actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, identity_link_id=UUID(link.id), + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), correlation_id=uuid4(), + ) + + +async def test_two_granted_claimants_have_one_atomic_winner(task_client, monkeypatch): + project = await create_active_project(task_client) + task = await create_ready_task(task_client, project["id"]) + actors = [] + for subject in ("first-claimant", "second-claimant"): + granted = await admit_and_grant_project_submitter(task_client, monkeypatch, project["id"], subject) + actors.append(await actor_context(granted["actor_profile_id"])) + both_attempting_lock = asyncio.Barrier(2) + original = TaskRepository.get_task + + async def align_before_task_lock(repository, task_id, *, for_update=False): + if for_update: + await both_attempting_lock.wait() + return await original(repository, task_id, for_update=for_update) + + monkeypatch.setattr(TaskRepository, "get_task", align_before_task_lock) + + async def claim(context): + async with db_session.get_session_factory()() as session: + return await AuthorizedTaskCommands( + session, authorization=PreparedTaskAuthorization(session, context), + audit=task_transition_audit(session), + actor_profile_id=context.actor_profile_id, + ).claim(UUID(task["id"]), "Competing claim") + + results = await asyncio.wait_for( + asyncio.gather(*(claim(actor) for actor in actors), return_exceptions=True), timeout=30, + ) + winners = [result for result in results if isinstance(result, TaskWithAssignmentResponse)] + losers = [result for result in results if isinstance(result, TaskAuthorityDenied)] + assert len(winners) == len(losers) == 1, results + winner = winners[0] + async with db_session.get_session_factory()() as session: + assignments = list(await session.scalars(select(TaskAssignment).where(TaskAssignment.task_id == task["id"]))) + assert len(assignments) == 1 and assignments[0].id == winner.assignment.id + stored_task = await session.get(WorkstreamTask, task["id"]) + assert stored_task.status == "claimed" and stored_task.assigned_to == winner.assignment.contributor_id + decisions = list(await session.scalars(select(AuditEvent).where(AuditEvent.action_id == "task.claim"))) + assert len(decisions) == 1 and decisions[0].after_facts["allowed"] is True + events = list(await session.scalars(select(AuditEvent).where( + AuditEvent.entity_id == task["id"], AuditEvent.event_type == "TaskClaimed", + ))) + assert len(events) == 1 + assert events[0].event_payload["references"]["authorization_decision_id"] == decisions[0].id + + +@pytest.mark.parametrize("ordering", ["revoke_first", "claim_first"]) +async def test_project_grant_revocation_serializes_with_claim( + task_client, task_database_env, monkeypatch, ordering, +): + """Real revocation API and task command share the same grant-row fence.""" + project = await create_active_project(task_client) + task = await create_ready_task(task_client, project["id"]) + grant = await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "revocation-race-submitter", + ) + context = await actor_context(grant["actor_profile_id"]) + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + locked, release = asyncio.Event(), asyncio.Event() + revoke_name = f"task-grant-revoke-{uuid4().hex}" + claim_name = f"task-grant-claim-{uuid4().hex}" + original_grant_lock = AdminAuthorizationRepository.lock_project_role_grant + original_prepare = PreparedTaskAuthorization.prepare + + async def observe_revoke_lock(repository, *, project_id, grant_id): + await repository._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": revoke_name}, + ) + row = await original_grant_lock(repository, project_id=project_id, grant_id=grant_id) + if ordering == "revoke_first": + locked.set() + await release.wait() + return row + + async def observe_claim_lock(authority, facts): + handle = await original_prepare(authority, facts) + if ordering == "claim_first": + locked.set() + await release.wait() + return handle + + monkeypatch.setattr(AdminAuthorizationRepository, "lock_project_role_grant", observe_revoke_lock) + monkeypatch.setattr(PreparedTaskAuthorization, "prepare", observe_claim_lock) + engine = create_async_engine(task_database_env, connect_args={ + "server_settings": {"application_name": claim_name}, + }) + revoke_engine = create_async_engine(task_database_env, connect_args={ + "server_settings": {"application_name": revoke_name}, + }) + app = task_client._transport.app + previous_session_dependency = app.dependency_overrides.get(get_db_session) + + async def named_revoke_session(): + async with AsyncSession(revoke_engine, expire_on_commit=False) as session: + yield session + + # Name the actual request session before any AUTH preparation can lock a + # target row, not only after the router reaches its mutation repository. + app.dependency_overrides[get_db_session] = named_revoke_session + + async def claim(): + async with AsyncSession(engine, expire_on_commit=False) as session: + return await AuthorizedTaskCommands( + session, authorization=PreparedTaskAuthorization(session, context), + audit=task_transition_audit(session), actor_profile_id=context.actor_profile_id, + ).claim(UUID(task["id"]), "Claim racing with authority revocation") + + async def revoke(): + return await task_client.post( + f"/api/v1/projects/{project['id']}/role-grants/{grant['grant_id']}/revoke", + headers=auth_headers(), json={"reason": "Concurrent grant revocation"}, + ) + + pending = [] + try: + first = revoke if ordering == "revoke_first" else claim + second = claim if ordering == "revoke_first" else revoke + pending.append(asyncio.create_task(first())) + await asyncio.wait_for(locked.wait(), timeout=30) + pending.append(asyncio.create_task(second())) + await asyncio.wait_for(wait_for_named_database_lock( + task_database_env, claim_name if ordering == "revoke_first" else revoke_name, + ), timeout=30) + release.set() + results = await asyncio.wait_for( + asyncio.gather(*pending, return_exceptions=True), timeout=30, + ) + finally: + release.set() + for running in pending: + if not running.done(): + running.cancel() + await asyncio.gather(*pending, return_exceptions=True) + await engine.dispose() + await revoke_engine.dispose() + if previous_session_dependency is None: + app.dependency_overrides.pop(get_db_session, None) + else: + app.dependency_overrides[get_db_session] = previous_session_dependency + revoke_result, claim_result = results if ordering == "revoke_first" else results[::-1] + assert not isinstance(revoke_result, BaseException), revoke_result + assert revoke_result.status_code == 200, revoke_result.text + async with db_session.get_session_factory()() as session: + stored_grant = await session.get(ProjectRoleGrant, UUID(grant["grant_id"])) + assert stored_grant.status == "revoked" + stored_task = await session.get(WorkstreamTask, task["id"]) + assignments = list(await session.scalars(select(TaskAssignment).where( + TaskAssignment.task_id == task["id"], + ))) + if ordering == "revoke_first": + assert isinstance(claim_result, TaskAuthorityDenied), claim_result + assert stored_task.status == "ready" and stored_task.assigned_to is None + assert assignments == [] + else: + assert isinstance(claim_result, TaskWithAssignmentResponse), claim_result + assert stored_task.status == "claimed" + assert stored_task.assigned_to == grant["actor_profile_id"] + assert len(assignments) == 1 + assert assignments[0].id == claim_result.assignment.id diff --git a/backend/tests/authorization/task_authority/test_lifecycle_races.py b/backend/tests/authorization/task_authority/test_lifecycle_races.py new file mode 100644 index 000000000..cc49c3e2e --- /dev/null +++ b/backend/tests/authorization/task_authority/test_lifecycle_races.py @@ -0,0 +1,643 @@ +"""Independent-session contributor lifecycle versus task-authority races.""" + +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass +from uuid import UUID, uuid4 + +import pytest +from auth_concurrency_support import wait_for_named_database_lock +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, create_async_engine + +from app.adapters.audit import task_transition_audit +from app.db import session as db_session +from app.modules.actors.models import ActorIdentityLink +from app.modules.authorization.submission_creation_authorization import ( + PreparedSubmissionCreationAuthorization, +) +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + HumanAuthorizationContext, + IdentityLinkStatus, +) +from app.modules.authorization.task_authorization import PreparedTaskAuthorization +from app.modules.tasks.api import ( + SubmissionCreationAuthorityFacts, + SubmissionCreationUnavailable, + TaskAuthorityDenied, + TaskSubmissionContextRequest, +) +from app.modules.tasks.authorized_commands import AuthorizedTaskCommands +from app.modules.tasks.models import AuditEvent, TaskAssignment +from app.modules.tasks.repository import TaskRepository +from app.schemas.auth import ActorContext +from tests.test_tasks import ( + task_client as task_client, + task_database_env as task_database_env, + actor_id, + admit_and_grant_project_submitter, + create_active_project, + create_ready_task, + create_started_task, +) + + +async def _task_contributor_race_snapshot( + connection: AsyncConnection, + task_id: str, +) -> dict[str, object]: + """Capture every task-owned write surface relevant to contributor races.""" + task = ( + await connection.execute( + text("select status, assigned_to from workstream_tasks where id = :task_id"), + {"task_id": task_id}, + ) + ).one() + assignments = ( + await connection.execute( + text( + "select id, contributor_id, assigned_by, status, accepted_at, released_at " + "from task_assignments where task_id = :task_id order by id" + ), + {"task_id": task_id}, + ) + ).all() + submissions = ( + await connection.execute( + text( + "select id, contributor_id, version, status, locked_at " + "from submissions where task_id = :task_id order by version" + ), + {"task_id": task_id}, + ) + ).all() + evidence_count = await connection.scalar( + text( + "select count(*) from evidence_items evidence " + "join submissions submission on submission.id = evidence.submission_id " + "where submission.task_id = :task_id" + ), + {"task_id": task_id}, + ) + checker_run_count = await connection.scalar( + text("select count(*) from checker_runs where task_id = :task_id"), + {"task_id": task_id}, + ) + checker_result_count = await connection.scalar( + text("select count(*) from checker_results where task_id = :task_id"), + {"task_id": task_id}, + ) + audit_events = ( + await connection.execute( + text( + "select id, event_type, from_status, to_status, actor_id, event_payload " + "from audit_events where entity_type = 'task' and entity_id = :task_id " + "order by created_at, id" + ), + {"task_id": task_id}, + ) + ).all() + idempotency_count = await connection.scalar( + text("select count(*) from authority_idempotency_records") + ) + return { + "task": tuple(task), + "assignments": [tuple(row) for row in assignments], + "submissions": [tuple(row) for row in submissions], + "evidence_count": evidence_count, + "checker_run_count": checker_run_count, + "checker_result_count": checker_result_count, + "audit_events": [tuple(row) for row in audit_events], + "idempotency_count": idempotency_count, + } + + +async def _read_task_contributor_race_snapshot( + database_url: str, + task_id: str, +) -> dict[str, object]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return await _task_contributor_race_snapshot(connection, task_id) + finally: + await engine.dispose() + + +async def _run_contributor_lifecycle_write( + database_url: str, + *, + actor_profile_id: str, + identity_link_id: str, + transition: str, + task_id: str, + application_name: str, + entered: asyncio.Event, + locked: asyncio.Event | None = None, + release: asyncio.Event | None = None, + observe_task_after_lock: bool = False, +) -> dict[str, object] | None: + """Apply one canonical-order lifecycle write in an independent transaction.""" + engine = create_async_engine(database_url) + observed: dict[str, object] | None = None + try: + async with engine.begin() as connection: + await connection.execute( + text("select set_config('application_name', :name, true)"), + {"name": application_name}, + ) + entered.set() + await connection.execute( + text("select id from actor_profiles where id = :id for update"), + {"id": actor_profile_id}, + ) + await connection.execute( + text("select id from actor_identity_links where id = :id for update"), + {"id": identity_link_id}, + ) + if locked is not None: + locked.set() + if release is not None: + await release.wait() + if observe_task_after_lock: + observed = await _task_contributor_race_snapshot(connection, task_id) + + if transition == "suspend": + await connection.execute( + text( + "update actor_profiles set status = 'suspended', " + "suspended_by = :actor_id, suspended_at = clock_timestamp(), " + "suspension_reason = 'contributor lock race' where id = :actor_id" + ), + {"actor_id": actor_profile_id}, + ) + elif transition == "deactivate": + await connection.execute( + text( + "update actor_profiles set status = 'deactivated', " + "deactivated_by = :actor_id, deactivated_at = clock_timestamp(), " + "deactivation_reason = 'contributor lock race' where id = :actor_id" + ), + {"actor_id": actor_profile_id}, + ) + else: + assert transition == "revoke_link" + await connection.execute( + text( + "update actor_identity_links set status = 'revoked', " + "revoked_by = :actor_id, revoked_at = clock_timestamp(), " + "revoked_reason = 'contributor lock race' where id = :link_id" + ), + {"actor_id": actor_profile_id, "link_id": identity_link_id}, + ) + return observed + finally: + await engine.dispose() + + +async def _consume_submission_authority(session, context, task_id): + """Prove AUTH commit under real TASK locks, not ART/Submission creation.""" + async with session.begin(): + assignment = await session.scalar(select(TaskAssignment).where( + TaskAssignment.task_id == task_id, TaskAssignment.status == "active", + )) + assert assignment is not None + task_context = await TaskRepository(session).lock_submission_context( + TaskSubmissionContextRequest( + task_id=UUID(task_id), assignment_id=UUID(assignment.id), + contributor_id=context.actor_profile_id, predecessor_submission_id=None, + ) + ) + facts = SubmissionCreationAuthorityFacts( + task_id=UUID(task_id), assignment_id=UUID(assignment.id), + contributor_id=context.actor_profile_id, admission_id=uuid4(), + predecessor_submission_id=None, submission_id=uuid4(), + submission_version=1, task_context=task_context, + ) + authority = PreparedSubmissionCreationAuthorization(session, context) + await authority.authorize(facts) + handle = await authority.prepare(facts) + try: + await authority.consume(handle, facts) + finally: + authority.close(handle) + events = list(await session.scalars(select(AuditEvent).where( + AuditEvent.request_id == context.request_id, + ))) + assert len(events) == 1 + return events[0] + + +async def _run_task_contributor_write( + database_url: str, + *, + actor: ActorContext, + task_id: str, + operation: str, + application_name: str, + entered: asyncio.Event, +) -> object: + """Commit a claim or a submission AUTH decision in a named PostgreSQL session.""" + engine = create_async_engine( + database_url, + connect_args={"server_settings": {"application_name": application_name}}, + ) + try: + async with AsyncSession(engine, expire_on_commit=False) as session: + entered.set() + link_id = await session.scalar( + select(ActorIdentityLink.id).where( + ActorIdentityLink.actor_profile_id == actor.actor_id, + ) + ) + assert link_id is not None + await session.rollback() + # Cached active facts must not overrule the concurrently locked rows. + context = HumanAuthorizationContext( + actor_profile_id=UUID(actor.actor_id), + actor_kind=ActorKind.HUMAN, actor_status=ActorStatus.ACTIVE, + identity_link_id=UUID(link_id), identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), correlation_id=uuid4(), + ) + if operation == "claim": + return await AuthorizedTaskCommands( + session, + authorization=PreparedTaskAuthorization(session, context), + audit=task_transition_audit(session), + actor_profile_id=context.actor_profile_id, + ).claim(UUID(task_id), "contributor lock race") + assert operation == "submission_authority" + return await _consume_submission_authority(session, context, task_id) + finally: + await engine.dispose() + + +async def _read_contributor_lifecycle_state( + database_url: str, + actor_profile_id: str, + identity_link_id: str, +) -> tuple[str, str]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + profile_status = await connection.scalar( + text("select status from actor_profiles where id = :id"), + {"id": actor_profile_id}, + ) + link_status = await connection.scalar( + text("select status from actor_identity_links where id = :id"), + {"id": identity_link_id}, + ) + assert isinstance(profile_status, str) + assert isinstance(link_status, str) + return profile_status, link_status + finally: + await engine.dispose() + + +async def _restore_contributor_after_lifecycle_race( + database_url: str, + actor_profile_id: str, + identity_link_id: str, +) -> None: + """Return a terminal test actor to active state under explicit test custody.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + reset = await connection.begin() + try: + await connection.execute( + text("alter table actor_profiles disable trigger actor_profile_history_guard") + ) + await connection.execute( + text( + "alter table actor_identity_links disable trigger " + "actor_identity_link_history_guard" + ) + ) + await connection.execute( + text( + "update actor_profiles set status = 'active', " + "suspended_by = null, suspended_at = null, " + "suspension_reason = null, reactivated_by = null, " + "reactivated_at = null, reactivation_reason = null, " + "deactivated_by = null, deactivated_at = null, " + "deactivation_reason = null where id = :id" + ), + {"id": actor_profile_id}, + ) + await connection.execute( + text( + "update actor_identity_links set status = 'active', " + "revoked_by = null, revoked_at = null, revoked_reason = null, " + "reactivated_by = null, reactivated_at = null, " + "reactivation_reason = null where id = :id" + ), + {"id": identity_link_id}, + ) + await reset.commit() + except BaseException: + await reset.rollback() + raise + finally: + enable = await connection.begin() + try: + await connection.execute( + text( + "alter table actor_identity_links enable trigger " + "actor_identity_link_history_guard" + ) + ) + await connection.execute( + text( + "alter table actor_profiles enable trigger actor_profile_history_guard" + ) + ) + await enable.commit() + except BaseException: + await enable.rollback() + raise + finally: + await engine.dispose() + + +@pytest.fixture +async def contributor_lifecycle_race_cleanup( + task_database_env: str, +) -> AsyncIterator[list[tuple[str, str]]]: + """Restore lifecycle race actors before the migration fixture downgrades.""" + actors: list[tuple[str, str]] = [] + yield actors + for actor_profile_id, identity_link_id in actors: + await _restore_contributor_after_lifecycle_race( + task_database_env, + actor_profile_id, + identity_link_id, + ) + + +@dataclass +class ContributorRace: + contributor_id: str + identity_link_id: str + actor: ActorContext + task_id: str + before: dict[str, object] + task_application_name: str + lifecycle_application_name: str + + +async def _prepare_contributor_race( + task_client, + task_database_env, + contributor_lifecycle_race_cleanup, + monkeypatch, + operation, + transition, + ordering, +) -> ContributorRace: + """Construct one race's prerequisites without performing the competing writes.""" + project = await create_active_project(task_client) + subject = f"race-{operation}-{transition}-{ordering}" + contributor_id = actor_id(subject) + if operation == "claim": + task = await create_ready_task(task_client, project["id"]) + await admit_and_grant_project_submitter(task_client, monkeypatch, project["id"], subject) + else: + assert operation == "submission_authority" + task = await create_started_task(task_client, project["id"], monkeypatch, subject) + async with db_session.get_session_factory()() as session: + identity_link_id = await session.scalar( + select(ActorIdentityLink.id).where(ActorIdentityLink.actor_profile_id == contributor_id) + ) + assert identity_link_id is not None + contributor_lifecycle_race_cleanup.append((contributor_id, identity_link_id)) + actor = ActorContext( + actor_id=contributor_id, + external_subject=subject, + external_issuer="flow-test", + roles=("worker",), + claim_snapshot={"roles": ["worker"]}, + auth_source="dev_mock", + is_dev_auth=True, + ) + task_id = task["id"] + before = await _read_task_contributor_race_snapshot(task_database_env, task_id) + operation_label = "submit-auth" if operation == "submission_authority" else operation + task_application_name = f"ws-race-{operation_label}-{transition}-{ordering}-task" + lifecycle_application_name = f"ws-race-{operation_label}-{transition}-{ordering}-lifecycle" + return ContributorRace( + contributor_id, + identity_link_id, + actor, + task_id, + before, + task_application_name, + lifecycle_application_name, + ) + + +async def _assert_final_lifecycle(task_database_env, race, transition): + profile_status, link_status = await _read_contributor_lifecycle_state( + task_database_env, race.contributor_id, race.identity_link_id + ) + if transition == "suspend": + assert (profile_status, link_status) == ("suspended", "active") + elif transition == "deactivate": + assert (profile_status, link_status) == ("deactivated", "active") + else: + assert (profile_status, link_status) == ("active", "revoked") + + +@pytest.mark.parametrize( + ("operation", "transition"), + [ + (operation, transition) + for operation in ("claim", "submission_authority") + for transition in ("suspend", "deactivate", "revoke_link") + ], +) +async def test_lifecycle_change_before_contributor_operation_denies_without_effects( + task_client, + task_database_env, + contributor_lifecycle_race_cleanup, + monkeypatch, + operation, + transition, +): + race = await _prepare_contributor_race( + task_client, + task_database_env, + contributor_lifecycle_race_cleanup, + monkeypatch, + operation, + transition, + "lifecycle_first", + ) + lifecycle_entered = asyncio.Event() + lifecycle_locked = asyncio.Event() + release_lifecycle = asyncio.Event() + lifecycle_call = asyncio.create_task( + _run_contributor_lifecycle_write( + task_database_env, + actor_profile_id=race.contributor_id, + identity_link_id=race.identity_link_id, + transition=transition, + task_id=race.task_id, + application_name=race.lifecycle_application_name, + entered=lifecycle_entered, + locked=lifecycle_locked, + release=release_lifecycle, + ), + name=race.lifecycle_application_name, + ) + await lifecycle_entered.wait() + await lifecycle_locked.wait() + task_entered = asyncio.Event() + task_call = asyncio.create_task( + _run_task_contributor_write( + task_database_env, + actor=race.actor, + task_id=race.task_id, + operation=operation, + application_name=race.task_application_name, + entered=task_entered, + ), + name=race.task_application_name, + ) + await task_entered.wait() + lock_error: AssertionError | None = None + try: + await wait_for_named_database_lock(task_database_env, race.task_application_name) + except AssertionError as exc: + lock_error = exc + finally: + release_lifecycle.set() + lifecycle_result, task_result = await asyncio.gather( + lifecycle_call, task_call, return_exceptions=True + ) + if lock_error is not None: + raise lock_error + assert lifecycle_result is None + expected_denial = TaskAuthorityDenied if operation == "claim" else SubmissionCreationUnavailable + assert isinstance(task_result, expected_denial), task_result + assert ( + await _read_task_contributor_race_snapshot(task_database_env, race.task_id) == race.before + ) + await _assert_final_lifecycle(task_database_env, race, transition) + + +@pytest.mark.parametrize( + ("operation", "transition"), + [ + (operation, transition) + for operation in ("claim", "submission_authority") + for transition in ("suspend", "deactivate", "revoke_link") + ], +) +async def test_contributor_operation_commits_before_lifecycle_change( + task_client, + task_database_env, + contributor_lifecycle_race_cleanup, + monkeypatch, + operation, + transition, +): + race = await _prepare_contributor_race( + task_client, + task_database_env, + contributor_lifecycle_race_cleanup, + monkeypatch, + operation, + transition, + "task_write_first", + ) + task_locked = asyncio.Event() + release_task = asyncio.Event() + if operation == "claim": + original_prepare = PreparedTaskAuthorization.prepare + + async def hold_prepared_claim(authority, facts): + handle = await original_prepare(authority, facts) + task_locked.set() + await release_task.wait() + return handle + + monkeypatch.setattr(PreparedTaskAuthorization, "prepare", hold_prepared_claim) + else: + original_submission_prepare = PreparedSubmissionCreationAuthorization.prepare + + async def hold_prepared_submission_authority(authority, facts): + handle = await original_submission_prepare(authority, facts) + task_locked.set() + await release_task.wait() + return handle + + monkeypatch.setattr( + PreparedSubmissionCreationAuthorization, "prepare", hold_prepared_submission_authority + ) + task_entered = asyncio.Event() + task_call = asyncio.create_task( + _run_task_contributor_write( + task_database_env, + actor=race.actor, + task_id=race.task_id, + operation=operation, + application_name=race.task_application_name, + entered=task_entered, + ), + name=race.task_application_name, + ) + await task_entered.wait() + await task_locked.wait() + lifecycle_entered = asyncio.Event() + lifecycle_call = asyncio.create_task( + _run_contributor_lifecycle_write( + task_database_env, + actor_profile_id=race.contributor_id, + identity_link_id=race.identity_link_id, + transition=transition, + task_id=race.task_id, + application_name=race.lifecycle_application_name, + entered=lifecycle_entered, + observe_task_after_lock=True, + ), + name=race.lifecycle_application_name, + ) + await lifecycle_entered.wait() + lock_error = None + try: + await wait_for_named_database_lock(task_database_env, race.lifecycle_application_name) + except AssertionError as exc: + lock_error = exc + finally: + release_task.set() + task_result, observed_after_task_commit = await asyncio.gather( + task_call, lifecycle_call, return_exceptions=True + ) + if lock_error is not None: + raise lock_error + if isinstance(task_result, BaseException): + raise task_result + if isinstance(observed_after_task_commit, BaseException): + raise observed_after_task_commit + assert isinstance(observed_after_task_commit, dict) + if operation == "claim": + assert task_result.assignment.contributor_id == race.contributor_id + assert observed_after_task_commit["task"] == ("claimed", race.contributor_id) + assignments = observed_after_task_commit["assignments"] + assert isinstance(assignments, list) + assert len(assignments) == 1 + assert assignments[0][1] == race.contributor_id + else: + assert isinstance(task_result, AuditEvent) + assert task_result.event_type == "SensitiveAuthorizationAllowed" + assert task_result.action_id == "submission.create" + assert task_result.actor_id == race.contributor_id + assert task_result.matched_grant_id is not None + # This branch commits AUTH evidence only. Artifact consumption, + # Submission creation and checker dispatch are different owner proofs. + assert observed_after_task_commit == race.before + await _assert_final_lifecycle(task_database_env, race, transition) diff --git a/backend/tests/authorization/task_authority/test_postgresql.py b/backend/tests/authorization/task_authority/test_postgresql.py new file mode 100644 index 000000000..e70215c0c --- /dev/null +++ b/backend/tests/authorization/task_authority/test_postgresql.py @@ -0,0 +1,148 @@ +"""Real grants and transaction-bound task authority, without fake allow ports.""" + +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import select + +from app.db import session as db_session +from app.modules.actors.models import ActorIdentityLink +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + HumanAuthorizationContext, + IdentityLinkStatus, +) +from app.modules.authorization.task_authorization import PreparedTaskAuthorization +from app.modules.tasks.api.authorization import ( + TaskAuthorityDenied, + TaskAuthorityFacts, + TaskAuthorityOperation, +) +from app.modules.tasks.models import AuditEvent +from tests.authorization.admin_access.support import create_project + + +async def project_manager(access, project): + manager = await access.signed.actor("project-manager") + await access.signed.grant(access.admin, manager, role="project_manager", project_id=project) + return manager + + +async def grant(access, manager, project, role="submitter"): + response = await access.signed.client.post( + f"/api/v1/projects/{project}/role-grants", + headers=manager.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": str(access.target.id), + "role": role, + "qualification": { + "skills_snapshot": { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + "reputation_snapshot": { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + "prior_project_work_refs": [], + "external_expertise_refs": [], + }, + "reason": "Assign contributor for exact task authority proof", + }, + ) + assert response.status_code == 201, response.text + return response.json()["id"] + + +async def context(access): + async with db_session.get_session_factory()() as session: + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.actor_profile_id == str(access.target.id), + ) + ) + return HumanAuthorizationContext( + actor_profile_id=access.target.id, + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=UUID(link.id), + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), + correlation_id=uuid4(), + ) + + +def facts(access, project): + return TaskAuthorityFacts( + operation=TaskAuthorityOperation.CLAIM, + task_id=uuid4(), + project_id=project, + actor_profile_id=access.target.id, + task_status="ready", + assigned_to=None, + assignment_id=None, + assignment_contributor_id=None, + locked_context_hash="sha256:" + "a" * 64, + ) + + +@pytest.mark.asyncio +async def test_real_project_grant_allows_exact_task_authority(admin_access): + access = admin_access + project = await create_project("Task authority") + manager = await project_manager(access, project) + grant_id = await grant(access, manager, project) + request_context = await context(access) + exact = facts(access, project) + async with db_session.get_session_factory()() as session: + authority = PreparedTaskAuthorization(session, request_context) + async with session.begin(): + handle = await authority.prepare(exact) + decision = await authority.consume(handle, exact) + row = await session.get(AuditEvent, str(decision)) + assert row.matched_grant_id == grant_id + assert row.action_id == "task.claim" and row.permission_id == "task.claim" + assert row.project_id == str(project) + assert row.after_facts["allowed"] is True + assert row.after_facts["resource_context_digest"].startswith("sha256:") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", ["absent", "reviewer", "foreign", "revoked"]) +async def test_real_inapplicable_grants_deny_and_leave_no_allow_evidence(admin_access, case): + access = admin_access + project = await create_project("Task authority denied") + if case != "absent": + grant_project = await create_project("Foreign project") if case == "foreign" else project + manager = await project_manager(access, grant_project) + grant_id = await grant( + access, manager, grant_project, "reviewer" if case == "reviewer" else "submitter" + ) + if case == "revoked": + response = await access.signed.client.post( + f"/api/v1/projects/{project}/role-grants/{grant_id}/revoke", + headers=manager.headers | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Withdraw task authority"}, + ) + assert response.status_code == 200, response.text + request_context = await context(access) + async with db_session.get_session_factory()() as session: + authority = PreparedTaskAuthorization(session, request_context) + with pytest.raises(TaskAuthorityDenied) as caught: + async with session.begin(): + await authority.prepare(facts(access, project)) + assert await authority.restage_denial(caught.value) + await session.commit() + rows = list( + await session.scalars( + select(AuditEvent).where( + AuditEvent.request_id == request_context.request_id, + ) + ) + ) + assert len(rows) == 1 and rows[0].event_type == "SensitiveAuthorizationDenied" + assert rows[0].project_id == str(project) + assert rows[0].after_facts["allowed"] is False diff --git a/backend/tests/authorization/task_authority/test_prepared.py b/backend/tests/authorization/task_authority/test_prepared.py new file mode 100644 index 000000000..232decb12 --- /dev/null +++ b/backend/tests/authorization/task_authority/test_prepared.py @@ -0,0 +1,329 @@ +"""Real kernel/PREP decisions with bounded repository and evidence doubles.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.modules.authorization.catalogue import ActionId +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.task_authorization import PreparedTaskAuthorization +from app.modules.tasks.api import TaskAuthorityDenied, TaskAuthorityFacts, TaskAuthorityOperation +from app.modules.authorization.domain.task_authority import TaskAuthorityResourceContext +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + HumanAuthorizationContext, + ServiceAuthorizationContext, + IdentityLinkStatus, + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, +) + + +class Session: + def __init__(self): + self.root = SimpleNamespace(is_active=True) + self.sync_session = self + + def get_transaction(self): + return self.root + + def in_nested_transaction(self): + return False + + +class Repository: + def __init__(self, context, project, *, granted=True, status="active", link_status="active"): + self.context, self.project = context, project + self.granted, self.status, self.link_status = granted, status, link_status + self.calls = [] + self.grant = SimpleNamespace(id=str(uuid4()), status="active") + + async def lock_request_actor(self, link, actor): + self.calls.append("actor_link") + assert link == self.context.identity_link_id and actor == self.context.actor_profile_id + return ( + SimpleNamespace(id=str(link), actor_profile_id=str(actor), status=self.link_status), + SimpleNamespace(id=str(actor), actor_kind="human", status=self.status), + ) + + async def find_active_project_role(self, *, project_id, actor_profile_id, role, for_update): + self.calls.append("grant") + assert ( + for_update and role == "submitter" and actor_profile_id == self.context.actor_profile_id + ) + return self.grant if self.granted and project_id == self.project else None + + +class Evidence: + def __init__(self): + self.events = [] + + async def add_authority_event(self, event): + self.events.append(event) + + +def setup(*, granted=True, status="active", link_status="active"): + context = HumanAuthorizationContext( + actor_profile_id=uuid4(), + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), + correlation_id=uuid4(), + ) + project = uuid4() + session = Session() + repository = Repository( + context, project, granted=granted, status=status, link_status=link_status + ) + kernel = AuthorizationService(session, context, admin_repository=repository) + evidence = Evidence() + kernel._audit = evidence + prepared = PreparedAuthorizationService(session, context, kernel, repository) + resource = TaskAuthorityResourceContext( + resource_id=uuid4(), + scope_project_id=project, + actor_profile_id=context.actor_profile_id, + identity_link_id=context.identity_link_id, + task_status="ready", + assigned_to=None, + assignment_id=None, + assignment_contributor_id=None, + locked_context_hash="sha256:" + "a" * 64, + reason=None, + ) + return session, repository, prepared, resource, evidence + + +async def prepare(prepared, resource, action=ActionId.TASK_CLAIM): + value = PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") + ) + handle = await prepared.prepare( + action, + value, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ), + ) + return handle, value + + +@pytest.mark.asyncio +async def test_claim_consumes_exact_current_project_grant_and_records_evidence(): + _, repository, prepared, resource, evidence = setup() + try: + handle, value = await prepare(prepared, resource) + decision = await prepared.consume(handle, ActionId.TASK_CLAIM, value, resource) + assert decision.allowed and str(decision.matched_grant_id) == repository.grant.id + assert decision.matched_scope_project_id == resource.scope_project_id + assert repository.calls == ["actor_link", "grant"] + assert len(evidence.events) == 1 + assert evidence.events[0].project_id == str(resource.scope_project_id) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume(handle, ActionId.TASK_CLAIM, value, resource) + finally: + prepared.close() + + +@pytest.mark.parametrize("case", ["valid", "missing_profile", "missing_link", "foreign_link"]) +async def test_canonical_identity_selectors_and_missing_row_denials(case): + """Actual AUTH query construction and kernel decisions; SQL execution is doubled.""" + _, repository, prepared, resource, evidence = setup() + context = repository.context + profile = SimpleNamespace( + id=str(context.actor_profile_id), actor_kind="human", status="active", + ) + link = SimpleNamespace( + id=str(context.identity_link_id), actor_profile_id=str(context.actor_profile_id), + status="active", subject_kind="human", + ) + if case == "foreign_link": + link.actor_profile_id = str(uuid4()) + rows = [None] if case == "missing_profile" else [ + profile, None if case == "missing_link" else link, + ] + sql_session = SimpleNamespace(scalar=AsyncMock(side_effect=rows)) + repository.lock_request_actor = AdminAuthorizationRepository(sql_session).lock_request_actor + try: + if case == "valid": + handle, request = await prepare(prepared, resource) + decision = await prepared.consume(handle, ActionId.TASK_CLAIM, request, resource) + assert decision.allowed + assert str(decision.matched_grant_id) == repository.grant.id + assert repository.calls == ["grant"] + assert len(evidence.events) == 1 + else: + with pytest.raises(PreparedAuthorizationUnsupported): + await prepare(prepared, resource) + assert repository.calls == [] + assert evidence.events == [] + expected = [("actor_profiles", context.actor_profile_id)] + if case != "missing_profile": + expected.append(("actor_identity_links", context.identity_link_id)) + assert len(sql_session.scalar.await_args_list) == len(expected) + for call, (table, identity) in zip(sql_session.scalar.await_args_list, expected, strict=True): + statement = call.args[0] + assert statement.column_descriptions[0]["entity"].__tablename__ == table + assert list(statement.compile().params.values()) == [str(identity)] + assert statement._for_update_arg is not None + assert statement.get_execution_options()["populate_existing"] is True + finally: + prepared.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "settings", + [ + {"granted": False}, + {"status": "suspended"}, + {"status": "deactivated"}, + {"link_status": "revoked"}, + ], +) +async def test_fresh_authority_denies_despite_active_request_snapshot(settings): + _, _, prepared, resource, evidence = setup(**settings) + try: + with pytest.raises(PreparedAuthorizationUnsupported): + await prepare(prepared, resource) + assert evidence.events == [] # PREP failure is not an allow decision. + finally: + prepared.close() + + +@pytest.mark.asyncio +async def test_foreign_project_grant_does_not_prepare(): + _, repository, prepared, resource, _ = setup() + repository.project = uuid4() + try: + with pytest.raises(PreparedAuthorizationUnsupported): + await prepare(prepared, resource) + finally: + prepared.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "field,value", + [ + ("resource_id", uuid4()), + ("scope_project_id", uuid4()), + ("actor_profile_id", uuid4()), + ("identity_link_id", uuid4()), + ("locked_context_hash", "sha256:" + "b" * 64), + ("task_status", "claimed"), + ], +) +async def test_substituted_final_facts_cannot_consume(field, value): + _, _, prepared, resource, evidence = setup() + try: + handle, request = await prepare(prepared, resource) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, ActionId.TASK_CLAIM, request, resource.model_copy(update={field: value}) + ) + assert evidence.events == [] + finally: + prepared.close() + + +@pytest.mark.asyncio +async def test_start_requires_exact_assignment_not_only_submitter_grant(): + _, _, prepared, resource, evidence = setup() + resource = resource.model_copy( + update={ + "task_status": "claimed", + "assigned_to": resource.actor_profile_id, + "assignment_id": uuid4(), + "assignment_contributor_id": uuid4(), + } + ) + try: + handle, request = await prepare(prepared, resource, ActionId.TASK_START) + with pytest.raises(AuthorizationDenied): + await prepared.consume(handle, ActionId.TASK_START, request, resource) + assert len(evidence.events) == 1 + assert evidence.events[0].after_facts["allowed"] is False + finally: + prepared.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field", ["assigned_to", "assignment_id", "assignment_contributor_id"]) +@pytest.mark.parametrize("action", [ActionId.TASK_CLAIM, ActionId.TASK_WORK_CONTEXT_READ]) +async def test_ready_task_rejects_partial_assignment_facts(action, field): + """A project grant cannot authorize an inconsistent ready-task resource.""" + _, _, prepared, resource, evidence = setup() + resource = resource.model_copy(update={field: uuid4()}) + try: + handle, request = await prepare(prepared, resource, action) + with pytest.raises(AuthorizationDenied): + await prepared.consume(handle, action, request, resource) + assert len(evidence.events) == 1 + assert evidence.events[0].after_facts["allowed"] is False + assert evidence.events[0].denial_code == "resource_guard_denied" + finally: + prepared.close() + + +@pytest.mark.asyncio +async def test_commit_invalidates_prepared_task_authority(): + session, _, prepared, resource, evidence = setup() + try: + handle, request = await prepare(prepared, resource) + session.root = SimpleNamespace(is_active=True) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume(handle, ActionId.TASK_CLAIM, request, resource) + assert evidence.events == [] + finally: + prepared.close() + + +@pytest.mark.parametrize("identity", list(ServiceIdentity)) +@pytest.mark.parametrize("operation", list(TaskAuthorityOperation)) +async def test_service_task_denials_have_canonical_restageable_evidence(identity, operation): + """Every fixed service remains denied with exact AUTH evidence, not a silent guard.""" + context = ServiceAuthorizationContext( + actor_profile_id=uuid4(), actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, service_identity=identity, + request_id=uuid4(), correlation_id=uuid4(), + ) + authority = PreparedTaskAuthorization(Session(), context) + evidence = Evidence() + authority._kernel._audit = evidence + facts = TaskAuthorityFacts( + operation=operation, task_id=uuid4(), project_id=uuid4(), + actor_profile_id=context.actor_profile_id, task_status="ready", + assigned_to=None, assignment_id=None, assignment_contributor_id=None, + locked_context_hash="sha256:" + "a" * 64, + ) + with pytest.raises(TaskAuthorityDenied) as caught: + await authority.prepare(facts) + assert isinstance(caught.value.__cause__, AuthorizationDenied) + decision = caught.value.__cause__.decision + assert decision.allowed is False + assert decision.action_id.value == operation.value + assert decision.denial_code.value == "permission_not_granted" + assert len(evidence.events) == 1 + first = evidence.events[0] + assert first.project_id == str(facts.project_id) + assert first.after_facts["resource_context_digest"].startswith("sha256:") + evidence.events.clear() # Simulate discarding the command transaction's stage. + assert await authority.restage_denial(caught.value) is True + assert len(evidence.events) == 1 + assert evidence.events[0].after_facts == first.after_facts + assert evidence.events[0].project_id == first.project_id diff --git a/backend/tests/authorization/task_authority/test_public_surface.py b/backend/tests/authorization/task_authority/test_public_surface.py new file mode 100644 index 000000000..bd5c88495 --- /dev/null +++ b/backend/tests/authorization/task_authority/test_public_surface.py @@ -0,0 +1,29 @@ +"""Endpoint retirement is explicit; GET preservation cannot mask an old POST.""" + +from app.main import create_app + + +def test_task_public_surface_has_no_self_activation_or_packet_creation(): + schema = create_app().openapi() + paths = schema["paths"] + assert "/api/v1/workers/me/profile" not in paths + submissions = paths["/api/v1/tasks/{task_id}/submissions"] + assert "get" in submissions and "post" not in submissions + for path, method, action in ( + ("/api/v1/tasks/{task_id}/claim", "post", "task.claim"), + ("/api/v1/tasks/{task_id}/start", "post", "task.start"), + ("/api/v1/tasks/{task_id}/work-context", "get", "task.work_context.read"), + ("/api/v1/operations/tasks/{task_id}/start", "post", "operations.task.start_override"), + ("/api/v1/projects/{project_id}/tasks/{task_id}/work-context", "get", "project.task.work_context.read"), + ): + operation = paths[path][method] + assert operation["security"] + assert "200" in operation["responses"] + assert operation["x-workstream-action-id"] == action + assert all(parameter["schema"]["format"] == "uuid" + for parameter in operation["parameters"] if parameter["in"] == "path") + assert "LegacyWorkflowEligibilityActivationRequest" not in schema["components"]["schemas"] + transition = schema["components"]["schemas"]["TaskTransitionRequest"] + assert transition["additionalProperties"] is False + reason_types = transition["properties"]["reason"]["anyOf"] + assert next(item for item in reason_types if item["type"] == "string")["maxLength"] == 1000 diff --git a/backend/tests/authorization/task_authority/test_shared_project_authority.py b/backend/tests/authorization/task_authority/test_shared_project_authority.py new file mode 100644 index 000000000..60c20bb17 --- /dev/null +++ b/backend/tests/authorization/task_authority/test_shared_project_authority.py @@ -0,0 +1,95 @@ +"""The shared authority-lock helper preserves the distinct guide-ingest guard.""" + +from uuid import UUID, uuid4 + +import pytest + +from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.runtime import ( + GuideSourceIngestResourceContext, HumanAuthorizationContext, MatchedAuthorityKind, + PreparedAuthorizationHandleInvalid, PreparedAuthorizationInput, + PreparedAuthorityScope, PreparedAuthorityScopeKind, +) +from tests.test_authorization import ( + _PreparedAdminFacts, _PreparedTestSession, _runtime_context, _runtime_service, +) + + +@pytest.mark.asyncio +async def test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant(): + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + authorization, evidence = _runtime_service(context, session=session) + facts = _PreparedAdminFacts(context) + authorization._admin = facts # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + facts, # type: ignore[arg-type] + ) + try: + project_id = uuid4() + caller_input = PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value={"project_id": str(project_id)} + ) + handle = await prepared.prepare( + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ), + ) + assert (facts.calls, facts.grant_calls) == (1, 1) + assert facts.grant_requests == [ + ( + (context.actor_profile_id, PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST), + {"scope_project_id": project_id, "system_scope_only": False, "for_update": True}, + ) + ] + + def resource(scope_project_id: UUID) -> GuideSourceIngestResourceContext: + return GuideSourceIngestResourceContext( + resource_type="project", + resource_id=scope_project_id, + scope_project_id=scope_project_id, + guide_id=uuid4(), + guide_source_snapshot_id=uuid4(), + guide_source_item_id=uuid4(), + operation_identity="sha256:" + "b" * 64, + request_digest="sha256:" + "c" * 64, + sha256="sha256:" + "d" * 64, + byte_count=17, + media_type="application/octet-stream", + ) + + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + caller_input, + resource(uuid4()), + ) + assert evidence.events == [] + decision = await prepared.consume( + handle, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + caller_input, + resource(project_id), + ) + assert decision.allowed is True + assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + assert decision.matched_grant_id == facts.grant_id + assert decision.matched_scope_project_id == project_id + assert (facts.calls, facts.grant_calls) == (1, 1) + assert len(evidence.events) == 1 + assert evidence.events[0].project_id == str(project_id) + assert evidence.events[0].after_facts is not None + assert evidence.events[0].after_facts["resource_context_digest"] == ( + decision.resource_context_digest + ) + finally: + prepared.close() diff --git a/backend/tests/authorization/task_authority/test_submission_authority.py b/backend/tests/authorization/task_authority/test_submission_authority.py new file mode 100644 index 000000000..54bf1445a --- /dev/null +++ b/backend/tests/authorization/task_authority/test_submission_authority.py @@ -0,0 +1,101 @@ +"""Real grant custody at the hidden AUTH port, not TASK/ART creation proof.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from app.db import session as db_session +from app.modules.authorization.submission_creation_authorization import ( + PreparedSubmissionCreationAuthorization, +) +from app.modules.tasks.api import ( + SubmissionCreationAuthorityFacts, SubmissionCreationUnavailable, + TaskLockedProjectContextReferences, TaskSubmissionContextFacts, +) +from app.modules.tasks.models import AuditEvent +from tests.authorization.admin_access.support import create_project +from tests.authorization.task_authority.test_postgresql import context, grant, project_manager + + +def authority_facts(actor_id, project_id): + """Supply TASK-port facts; this fixture does not claim to load or lock TASK rows.""" + task_id, assignment_id = uuid4(), uuid4() + task_context = TaskSubmissionContextFacts( + task_id=task_id, assignment_id=assignment_id, contributor_id=actor_id, + status="in_progress", kind="initial", predecessor=None, + locked_project_context=TaskLockedProjectContextReferences( + project_id=project_id, guide_version="1", source_snapshot_id=uuid4(), + source_snapshot_hash="sha256:" + "1" * 64, effective_policy_id=uuid4(), + effective_policy_hash="sha256:" + "2" * 64, pre_submit_policy_id=uuid4(), + pre_submit_policy_bundle_hash="sha256:" + "3" * 64, + ), + ) + return SubmissionCreationAuthorityFacts( + task_id=task_id, assignment_id=assignment_id, contributor_id=actor_id, + admission_id=uuid4(), predecessor_submission_id=None, + submission_id=uuid4(), submission_version=1, task_context=task_context, + ) + + +async def test_submission_authority_consumes_exact_project_grant(admin_access): + access = admin_access + project = await create_project("Submission authority") + manager = await project_manager(access, project) + grant_id = await grant(access, manager, project) + human = await context(access) + facts = authority_facts(access.target.id, project) + async with db_session.get_session_factory()() as session: + authority = PreparedSubmissionCreationAuthorization(session, human) + async with session.begin(): + await authority.authorize(facts) + handle = await authority.prepare(facts) + try: + await authority.consume(handle, facts) + finally: + authority.close(handle) + events = list(await session.scalars(select(AuditEvent).where( + AuditEvent.request_id == human.request_id, + ))) + assert len(events) == 1 + assert events[0].event_type == "SensitiveAuthorizationAllowed" + assert events[0].action_id == "submission.create" + assert events[0].matched_grant_id == grant_id + assert events[0].project_id == str(project) + + +@pytest.mark.parametrize("case", ["absent", "reviewer", "foreign", "revoked", "operator", "project_manager"]) +async def test_submission_authority_rejects_inapplicable_grants(admin_access, case): + access = admin_access + project = await create_project("Submission authority denied") + if case in {"operator", "project_manager"}: + await access.signed.grant(access.admin, access.target, role=case) + elif case != "absent": + grant_project = await create_project("Foreign submission project") if case == "foreign" else project + manager = await project_manager(access, grant_project) + grant_id = await grant( + access, manager, grant_project, "reviewer" if case == "reviewer" else "submitter", + ) + if case == "revoked": + response = await access.signed.client.post( + f"/api/v1/projects/{project}/role-grants/{grant_id}/revoke", + headers=manager.headers | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Withdraw submission authority"}, + ) + assert response.status_code == 200, response.text + human = await context(access) + facts = authority_facts(access.target.id, project) + async with db_session.get_session_factory()() as session: + authority = PreparedSubmissionCreationAuthorization(session, human) + with pytest.raises(SubmissionCreationUnavailable): + async with session.begin(): + await authority.authorize(facts) + handle = await authority.prepare(facts) + try: + await authority.consume(handle, facts) + finally: + authority.close(handle) + assert list(await session.scalars(select(AuditEvent).where( + AuditEvent.request_id == human.request_id, + AuditEvent.event_type == "SensitiveAuthorizationAllowed", + ))) == [] diff --git a/backend/tests/authorization/task_authority/test_submission_policy.py b/backend/tests/authorization/task_authority/test_submission_policy.py new file mode 100644 index 000000000..6482c4df8 --- /dev/null +++ b/backend/tests/authorization/task_authority/test_submission_policy.py @@ -0,0 +1,372 @@ +"""Real locked-policy rejection before hidden Submission persistence or ART access.""" + +from uuid import UUID, uuid4 +from unittest.mock import AsyncMock + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.db import session as db_session +from app.db import models as db_models +from app.db.errors import integrity_constraint_name +from app.api.deps.authorization import compose_hidden_submission_creation_command +from app.modules.projects.models import ( + EffectiveProjectSubmissionArtifactPolicy, + PostSubmitCheckerPolicy, + PreSubmitCheckerPolicy, +) +from app.modules.authorization.prepared import PreparedSubmissionCreationAuthorization +from app.modules.tasks.api import SubmissionCreationRequest +from app.modules.tasks.models import AuditEvent, Submission, TaskAssignment, WorkstreamTask +from app.modules.tasks.service import TaskLockedContextInvalid +from app.modules.tasks.submission_composition import TaskSubmissionCreationService +from tests.authorization.task_authority.test_concurrency import actor_context +from tests.test_tasks import ( + task_database_env as task_database_env, + task_client as task_client, + create_active_project, + create_started_task, +) + + +async def _create_hidden_submission(task_id: str): + """Exercise real TASK/AUTH composition; invalid policy must precede ART lookup. + + The deliberately nonexistent admission cannot produce successful creation. + If policy validation regresses, admission denial is a different failure and + does not satisfy these tests' precise locked-policy error assertions. + """ + async with db_session.get_session_factory()() as session: + assignment = await session.scalar(select(TaskAssignment).where( + TaskAssignment.task_id == task_id, TaskAssignment.status == "active", + )) + assert assignment is not None + assignment_id = UUID(assignment.id) + contributor_id = UUID(assignment.contributor_id) + context = await actor_context(str(contributor_id)) + async with db_session.get_session_factory()() as session: + command = compose_hidden_submission_creation_command( + session, context, request_id=context.request_id, correlation_id=context.correlation_id, + ) + return await command.create(SubmissionCreationRequest( + task_id=UUID(task_id), assignment_id=assignment_id, + contributor_id=contributor_id, admission_id=uuid4(), + predecessor_submission_id=None, summary="Completed work", + contributor_attestation="This submission is my work.", + )) + + +@pytest.mark.parametrize("field", [ + "locked_post_submit_checker_policy_body", + "locked_review_policy_hash", + "locked_revision_policy_hash", +]) +async def test_invalid_locked_policy_never_reaches_art_or_submission( + task_client, monkeypatch, field, +): + project = await create_active_project(task_client) + task_response = await create_started_task(task_client, project["id"], monkeypatch) + async with db_session.get_session_factory()() as session: + assignment = await session.scalar(select(TaskAssignment).where( + TaskAssignment.task_id == task_response["id"], + TaskAssignment.status == "active", + )) + assignment_id = UUID(assignment.id) + contributor_id = UUID(assignment.contributor_id) + context = await actor_context(str(contributor_id)) + admissions = AsyncMock() + admissions.consume.side_effect = AssertionError("invalid policy reached ART") + + async with db_session.get_session_factory()() as session: + expected_error = ( + TaskLockedContextInvalid + if field.endswith("body") else IntegrityError + ) + with pytest.raises(expected_error) as rejected: + async with session.begin(): + task = await session.get(WorkstreamTask, task_response["id"]) + original = getattr(task, field) + # Policy hashes have composite FK custody. Body corruption is + # rejected by the complete TASK policy validator instead. + setattr(task, field, {} if field.endswith("body") else "sha256:" + "f" * 64) + await session.flush() + await TaskSubmissionCreationService( + session, + authorization=PreparedSubmissionCreationAuthorization(session, context), + admissions=admissions, + ).create(SubmissionCreationRequest( + task_id=UUID(task.id), assignment_id=assignment_id, + contributor_id=contributor_id, admission_id=uuid4(), + predecessor_submission_id=None, summary="Completed work", + contributor_attestation="This submission is my work.", + )) + if expected_error is IntegrityError: + policy = "review" if "review" in field else "revision" + assert integrity_constraint_name(rejected.value) == ( + f"fk_workstream_tasks_locked_{policy}_policy" + ) + admissions.consume.assert_not_awaited() + assert await session.scalar(select(Submission).where( + Submission.task_id == task_response["id"], + )) is None + restored = await session.get(WorkstreamTask, task_response["id"]) + assert getattr(restored, field) == original + assert restored.status == "in_progress" + + +async def test_submission_pre_submit_rejects_mutated_effective_policy_body( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + started_task = await create_started_task(task_client, project["id"], monkeypatch) + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + assert task is not None + effective_policy = await session.get( + EffectiveProjectSubmissionArtifactPolicy, + task.locked_effective_project_submission_artifact_policy_id, + ) + assert effective_policy is not None + effective_policy.effective_policy = { + **effective_policy.effective_policy, + "required_evidence": [], + } + await session.commit() + + with pytest.raises(TaskLockedContextInvalid) as rejected: + await _create_hidden_submission(started_task["id"]) + assert rejected.value.status_code == 422 + assert rejected.value.code == "task_locked_context_invalid" + assert rejected.value.details["field"] == "locked_effective_project_submission_artifact_policy_hash" + + async with db_session.get_session_factory()() as session: + submissions = ( + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() + assert submissions == [] + assert checker_runs == [] + + +async def test_submission_pre_submit_checker_setup_error_is_controlled( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + started_task = await create_started_task(task_client, project["id"], monkeypatch) + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + assert task is not None + pre_submit_policy = await session.get( + PreSubmitCheckerPolicy, + task.locked_pre_submit_checker_policy_id, + ) + assert pre_submit_policy is not None + pre_submit_policy.checker_names = ["unknown_project_checker"] + await session.commit() + + with pytest.raises(TaskLockedContextInvalid) as rejected: + await _create_hidden_submission(started_task["id"]) + assert rejected.value.status_code == 422 + assert rejected.value.code == "task_locked_context_invalid" + assert rejected.value.details["field"] == "locked_pre_submit_checker_policy_id" + + async with db_session.get_session_factory()() as session: + submissions = ( + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + assert submissions == [] + + +async def test_submission_rejects_malformed_locked_post_submit_policy_body_without_side_effects( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + started_task = await create_started_task(task_client, project["id"], monkeypatch) + + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + assert task is not None + corrupted_body = dict(task.locked_post_submit_checker_policy_body or {}) + corrupted_body["required_checkers"] = [ + "check_policy_context_present", + "check_evidence_present", + ] + task.locked_post_submit_checker_policy_body = corrupted_body + await session.commit() + + with pytest.raises(TaskLockedContextInvalid) as rejected: + await _create_hidden_submission(started_task["id"]) + assert rejected.value.status_code == 422 + assert rejected.value.code == "task_locked_context_invalid" + assert rejected.value.details["field"] == "locked_post_submit_checker_policy_body" + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + submissions = ( + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + runs = ( + ( + await session.execute( + select(db_models.CheckerRun) + .join(Submission, db_models.CheckerRun.submission_id == Submission.id) + .where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + results = ( + ( + await session.execute( + select(db_models.CheckerResult) + .join(Submission, db_models.CheckerResult.submission_id == Submission.id) + .where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + audit_events = ( + ( + await session.execute( + select(AuditEvent).where(AuditEvent.entity_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + + assert task is not None + assert task.status == "in_progress" + assert submissions == [] + assert runs == [] + assert results == [] + assert "submission_created" not in {event.event_type for event in audit_events} + assert "submission_finalized" not in {event.event_type for event in audit_events} + assert "checker_run_triggered" not in {event.event_type for event in audit_events} + + +async def test_submission_pre_submit_rejects_mutated_compiled_checker_bundle( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + started_task = await create_started_task(task_client, project["id"], monkeypatch) + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + assert task is not None + pre_submit_policy = await session.get( + PreSubmitCheckerPolicy, + task.locked_pre_submit_checker_policy_id, + ) + assert pre_submit_policy is not None + pre_submit_policy.compiled_bundle = { + **pre_submit_policy.compiled_bundle, + "effective_policy_hash": "sha256:" + "0" * 64, + } + await session.commit() + + with pytest.raises(TaskLockedContextInvalid) as rejected: + await _create_hidden_submission(started_task["id"]) + assert rejected.value.status_code == 422 + assert rejected.value.code == "task_locked_context_invalid" + assert rejected.value.details["field"] == "locked_pre_submit_checker_bundle_hash" + + async with db_session.get_session_factory()() as session: + submissions = ( + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() + assert submissions == [] + assert checker_runs == [] + + +async def test_submission_rejects_crossed_post_submit_policy_sidecar( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + started_task = await create_started_task(task_client, project["id"], monkeypatch) + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + assert task is not None + locked_body = dict(task.locked_post_submit_checker_policy_body or {}) + post_submit_policy = await session.get( + PostSubmitCheckerPolicy, + task.locked_post_submit_checker_policy_id, + ) + assert post_submit_policy is not None + post_submit_policy.required_checkers = [ + *post_submit_policy.required_checkers, + "check_acceptance_criteria_present", + ] + audit_ids = sorted(await session.scalars(select(AuditEvent.id))) + await session.commit() + + with pytest.raises(TaskLockedContextInvalid) as rejected: + await _create_hidden_submission(started_task["id"]) + assert rejected.value.status_code == 422 + assert rejected.value.code == "task_locked_context_invalid" + assert rejected.value.details["field"] == "locked_post_submit_checker_policy_body" + + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + submissions = ( + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) + checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() + assert sorted(await session.scalars(select(AuditEvent.id))) == audit_ids + assert task is not None + assert task.status == "in_progress" + assert submissions == [] + assert task.locked_post_submit_checker_policy_body == locked_body + assert "check_acceptance_criteria_present" not in [ + entry["checker_id"] + for entry in locked_body["entries"] + if entry["classification"] == "project_required" + ] + assert "check_acceptance_criteria_present" not in [ + entry["checker_id"] for entry in locked_body["entries"] + ] + assert "check_required_files" in [ + entry["checker_id"] + for entry in locked_body["entries"] + if entry["classification"] == "platform_default" + ] + assert "check_required_files" in [entry["checker_id"] for entry in locked_body["entries"]] + assert checker_runs == [] diff --git a/backend/tests/authorization/task_authority/test_task_commands.py b/backend/tests/authorization/task_authority/test_task_commands.py new file mode 100644 index 000000000..026d8fddf --- /dev/null +++ b/backend/tests/authorization/task_authority/test_task_commands.py @@ -0,0 +1,457 @@ +"""Real task HTTP transitions; identity tokens do not grant project authority.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select +from sqlalchemy.exc import OperationalError + +from app.db import session as db_session +from app.modules.actors.models import ActorIdentityLink, ActorProfile, LegacyWorkflowEligibility +from app.modules.actors.service import ActorService, IdentityLinkRevoked +from app.modules.authorization.models import AdminRoleGrant, AuthorityControl, ProjectRoleGrant +from app.modules.authorization.runtime import AuthorizationEvidenceUnavailable +from app.modules.authorization.task_authorization import PreparedTaskAuthorization +from app.modules.tasks.api import TaskAuthorityDenied +from app.modules.audit.service import AuditService, LifecycleAuditParticipant +from app.modules.tasks.models import AuditEvent, TaskAssignment, WorkstreamTask +from app.modules.tasks.authorized_commands import AuthorizedTaskCommands +from app.modules.tasks.service import TaskServiceError +from tests.test_tasks import ( + task_database_env as task_database_env, + task_client as task_client, + create_active_project, + create_ready_task, + create_started_task, + complete_submission_payload, + admit_and_grant_project_submitter, + auth_headers, + set_dev_actor, +) +from tests.authorization.task_authority.test_lifecycle_races import ( + _read_task_contributor_race_snapshot, +) + + +@pytest.mark.parametrize("packet", ["complete", "forged_context", "empty"]) +async def test_retired_packet_post_cannot_mutate_an_authorized_assignment( + task_client, task_database_env, monkeypatch, packet, +): + """Even a current assigned Submitter cannot invoke the retired packet writer.""" + project = await create_active_project(task_client) + task = await create_started_task(task_client, project["id"], monkeypatch) + before = await _read_task_contributor_race_snapshot(task_database_env, task["id"]) + payload = complete_submission_payload() if packet != "empty" else {} + if packet == "forged_context": + payload.update(contributor_id=str(uuid4()), locked_guide_version="client-controlled") + response = await task_client.post( + f"/api/v1/tasks/{task['id']}/submissions", headers=auth_headers(), json=payload, + ) + assert response.status_code == 405, response.text + assert "POST" not in response.headers["allow"] + assert await _read_task_contributor_race_snapshot(task_database_env, task["id"]) == before + retained_read = await task_client.get( + f"/api/v1/tasks/{task['id']}/submissions", headers=auth_headers(), + ) + assert retained_read.status_code == 200, retained_read.text + assert retained_read.json() == [] + + +async def test_retired_worker_endpoint_cannot_admit_or_self_authorize(task_client, monkeypatch): + """A removed route is not a hidden activation route, regardless of token roles.""" + models = (ActorProfile, ActorIdentityLink, LegacyWorkflowEligibility, ProjectRoleGrant) + async with db_session.get_session_factory()() as session: + before = [await session.scalar(select(func.count()).select_from(model)) for model in models] + for roles in ("viewer", "worker", "admin"): + set_dev_actor(monkeypatch, roles=roles, subject=f"retired-route-{roles}") + response = await task_client.post( + "/api/v1/workers/me/profile", headers=auth_headers(), + json={"skills": ["python"], "display_name": "No self-activation"}, + ) + assert response.status_code == 404, response.text + anonymous = await task_client.post("/api/v1/workers/me/profile", json={}) + assert anonymous.status_code == 404, anonymous.text + async with db_session.get_session_factory()() as session: + after = [await session.scalar(select(func.count()).select_from(model)) for model in models] + assert after == before + + +async def test_project_grant_drives_claim_start_and_current_action_hints(task_client, monkeypatch): + project = await create_active_project(task_client) + task = await create_ready_task(task_client, project["id"]) + task_id = task["id"] + set_dev_actor(monkeypatch, roles="viewer", subject="explicit-submitter") + admitted = await task_client.get("/api/v1/actors/me", headers=auth_headers()) + assert admitted.status_code == 200, admitted.text + actor_id = admitted.json()["actor_profile_id"] + for method, suffix in (("post", "claim"), ("get", "work-context")): + denied = await getattr(task_client, method)( + f"/api/v1/tasks/{task_id}/{suffix}", + headers=auth_headers(), + ) + assert denied.status_code == 403, denied.text + assert denied.json()["error"]["code"] == "permission_not_granted" + assert denied.json()["error"]["retryable"] is False + assert denied.json()["error"]["correlation_id"] == denied.headers["x-correlation-id"] + async with db_session.get_session_factory()() as session: + untouched = await session.get(WorkstreamTask, task_id) + assert untouched.status == "ready" and untouched.assigned_to is None + assert ( + await session.scalar(select(TaskAssignment).where(TaskAssignment.task_id == task_id)) + is None + ) + + authority = await admit_and_grant_project_submitter( + task_client, + monkeypatch, + project["id"], + "explicit-submitter", + ) + assert authority["actor_profile_id"] == actor_id + context = await task_client.get(f"/api/v1/tasks/{task_id}/work-context", headers=auth_headers()) + assert context.status_code == 200, context.text + assert context.json()["lifecycle"]["next_actions"] == ["claim"] + claimed = await task_client.post(f"/api/v1/tasks/{task_id}/claim", headers=auth_headers()) + assert claimed.status_code == 200, claimed.text + assert claimed.json()["assignment"]["contributor_id"] == actor_id + assert claimed.json()["task"]["status"] == "claimed" + context = await task_client.get(f"/api/v1/tasks/{task_id}/work-context", headers=auth_headers()) + assert context.status_code == 200, context.text + assert context.json()["lifecycle"]["next_actions"] == ["start"] + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + revoked = await task_client.post( + f"/api/v1/projects/{project['id']}/role-grants/{authority['grant_id']}/revoke", + headers=auth_headers(), + json={"reason": "Withdraw project work authority"}, + ) + assert revoked.status_code == 200, revoked.text + set_dev_actor(monkeypatch, roles="viewer", subject="explicit-submitter") + blocked = await task_client.post(f"/api/v1/tasks/{task_id}/start", headers=auth_headers()) + assert blocked.status_code == 403, blocked.text + async with db_session.get_session_factory()() as session: + unchanged = await session.get(WorkstreamTask, task_id) + assert unchanged.status == "claimed" and unchanged.assigned_to == actor_id + assignment = await session.get(TaskAssignment, claimed.json()["assignment"]["id"]) + assert assignment.status == "active" and assignment.contributor_id == actor_id + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "explicit-submitter" + ) + started = await task_client.post(f"/api/v1/tasks/{task_id}/start", headers=auth_headers()) + assert started.status_code == 200, started.text + assert started.json()["status"] == "in_progress" + context = await task_client.get(f"/api/v1/tasks/{task_id}/work-context", headers=auth_headers()) + assert context.status_code == 200, context.text + assert context.json()["lifecycle"]["next_actions"] == [] + assert context.json()["lifecycle"]["can_submit"] is False + assert context.json()["lifecycle"]["can_run_pre_submit_check"] is False + + async with db_session.get_session_factory()() as session: + assert ( + await session.scalar( + select(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == actor_id, + ) + ) + is None + ) + assignments = list( + await session.scalars(select(TaskAssignment).where(TaskAssignment.task_id == task_id)) + ) + assert len(assignments) == 1 and assignments[0].contributor_id == actor_id + transitions = list( + await session.scalars( + select(AuditEvent).where( + AuditEvent.entity_id == task_id, + AuditEvent.to_status.in_(["claimed", "in_progress"]), + ) + ) + ) + assert {event.to_status for event in transitions} == {"claimed", "in_progress"} + for event in transitions: + assert event.actor_id == actor_id + assert event.actor_roles == [] and event.claim_snapshot == {} + assert event.event_payload["references"]["authorization_decision_id"] + + +async def test_task_command_routes_preserve_structured_errors(task_client, monkeypatch): + """Inject only owner failures; routing must retain request IDs and retry semantics.""" + task_id, project_id = uuid4(), uuid4() + routes = [ + ("claim", "POST", f"/api/v1/tasks/{task_id}/claim"), + ("start", "POST", f"/api/v1/tasks/{task_id}/start"), + ("start", "POST", f"/api/v1/operations/tasks/{task_id}/start"), + ("work_context", "GET", f"/api/v1/tasks/{task_id}/work-context"), + ("work_context", "GET", f"/api/v1/projects/{project_id}/tasks/{task_id}/work-context"), + ] + for exception, status, code, retryable in [ + (TaskServiceError("bounded task failure"), 400, "invalid_request", False), + (OperationalError("injected", None, RuntimeError("unavailable")), + 503, "task_authority_unavailable", True), + (AuthorizationEvidenceUnavailable("injected authority evidence failure"), + 503, "task_authority_unavailable", True), + ]: + async def fail(*args, **kwargs): + raise exception + + for owner_method, method, path in routes: + with monkeypatch.context() as patch: + patch.setattr(AuthorizedTaskCommands, owner_method, fail) + response = await task_client.request( + method, path, headers=auth_headers(), + **({"json": {"reason": "Explicit operation reason"}} if method == "POST" else {}), + ) + assert response.status_code == status, response.text + error = response.json()["error"] + assert error["code"] == code + assert error["retryable"] is retryable + assert error["correlation_id"] == response.headers["x-correlation-id"] + + +async def test_task_denial_evidence_failure_is_structured_unavailable(task_client, monkeypatch): + """Unavailable denial evidence must not become a bare 500 or successful denial.""" + async def deny(*args, **kwargs): + raise TaskAuthorityDenied("injected task denial") + + async def evidence_unavailable(*args, **kwargs): + raise AuthorizationEvidenceUnavailable("injected denial evidence failure") + + monkeypatch.setattr(AuthorizedTaskCommands, "claim", deny) + monkeypatch.setattr(PreparedTaskAuthorization, "restage_denial", evidence_unavailable) + response = await task_client.post( + f"/api/v1/tasks/{uuid4()}/claim", headers=auth_headers(), json={} + ) + assert response.status_code == 503, response.text + error = response.json()["error"] + assert error["code"] == "task_authority_unavailable" + assert error["retryable"] is True + assert error["correlation_id"] == response.headers["x-correlation-id"] + + +@pytest.mark.parametrize("failure_kind", ["identity_revoked", "database_unavailable"]) +async def test_actor_resolution_failure_cannot_reach_task_command( + task_client, task_database_env, monkeypatch, failure_kind, +): + """Registry failure rolls back its read transaction before any TASK command.""" + project = await create_active_project(task_client) + task = await create_ready_task(task_client, project["id"]) + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "resolution-failure-submitter" + ) + before = await _read_task_contributor_race_snapshot(task_database_env, task["id"]) + original = ActorService.find_actor_for_authorization + resolved_sessions = [] + rollback_observations = [] + command_calls = [] + + async def fail_after_lookup(service, token): + assert await original(service, token) is not None + assert service._session.in_transaction() + resolved_sessions.append(service._session) + rollback = service._session.rollback + + async def observe_rollback(): + was_active = service._session.in_transaction() + await rollback() + rollback_observations.append((was_active, service._session.in_transaction())) + + monkeypatch.setattr(service._session, "rollback", observe_rollback) + if failure_kind == "identity_revoked": + raise IdentityLinkRevoked("Identity link is revoked") + raise OperationalError("injected registry failure", None, RuntimeError("unavailable")) + + async def forbidden_command(*args, **kwargs): + command_calls.append(True) + raise AssertionError("Actor resolution failure reached TASK") + + monkeypatch.setattr(ActorService, "find_actor_for_authorization", fail_after_lookup) + monkeypatch.setattr(AuthorizedTaskCommands, "claim", forbidden_command) + response = await task_client.post( + f"/api/v1/tasks/{task['id']}/claim", headers=auth_headers(), json={} + ) + unavailable = failure_kind == "database_unavailable" + assert response.status_code == (503 if unavailable else 403), response.text + error = response.json()["error"] + assert error["code"] == ("service_unavailable" if unavailable else "identity_link_revoked") + assert error["retryable"] is unavailable + assert error["correlation_id"] == response.headers["x-correlation-id"] + assert "injected" not in response.text + assert len(resolved_sessions) == 1 + assert rollback_observations == [(True, False)] + assert not resolved_sessions[0].in_transaction() + assert command_calls == [] + assert await _read_task_contributor_race_snapshot(task_database_env, task["id"]) == before + + +@pytest.mark.parametrize("failure_phase", ["authority", "transition"]) +async def test_claim_rolls_back_and_retries_once_after_evidence_failure( + task_client, monkeypatch, failure_phase, +): + project = await create_active_project(task_client) + task = await create_ready_task(task_client, project["id"]) + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "rollback-submitter" + ) + owner, method = ( + (AuditService, "add_authority_event") + if failure_phase == "authority" + else (LifecycleAuditParticipant, "add_event") + ) + original = getattr(owner, method) + staged = [] + + async def fail_after_flush(participant, value): + event = await original(participant, value) + if failure_phase == "authority": + assert event.action_id == "task.claim" + staged.append(event.id) + raise OperationalError("injected audit storage failure", None, RuntimeError("unavailable")) + + headers = auth_headers() + path = f"/api/v1/tasks/{task['id']}/claim" + with monkeypatch.context() as patch: + patch.setattr(owner, method, fail_after_flush) + response = await task_client.post(path, headers=headers) + assert response.status_code == 503, response.text + assert response.json()["error"]["code"] == "task_authority_unavailable" + assert response.json()["error"]["retryable"] is True + assert len(staged) == 1 + async with db_session.get_session_factory()() as session: + unchanged = await session.get(WorkstreamTask, task["id"]) + assert unchanged.status == "ready" and unchanged.assigned_to is None + assert ( + await session.scalar(select(TaskAssignment).where(TaskAssignment.task_id == task["id"])) + is None + ) + assert await session.get(AuditEvent, staged[0]) is None + assert ( + await session.scalar(select(AuditEvent).where(AuditEvent.action_id == "task.claim")) + is None + ) + + # Repeat the identical request, including its key, after storage recovers. + retried = await task_client.post(path, headers=headers) + assert retried.status_code == 200, retried.text + assert retried.json()["task"]["status"] == "claimed" + async with db_session.get_session_factory()() as session: + assignments = list(await session.scalars( + select(TaskAssignment).where(TaskAssignment.task_id == task["id"]) + )) + assert len(assignments) == 1 + assert assignments[0].id == retried.json()["assignment"]["id"] + assert assignments[0].status == "active" + claimed = await session.get(WorkstreamTask, task["id"]) + assert claimed.status == "claimed" + assert claimed.assigned_to == assignments[0].contributor_id + decisions = list(await session.scalars( + select(AuditEvent).where(AuditEvent.action_id == "task.claim") + )) + transitions = list(await session.scalars( + select(AuditEvent).where( + AuditEvent.entity_id == task["id"], AuditEvent.to_status == "claimed", + ) + )) + assert len(decisions) == len(transitions) == 1 + decision, transition = decisions[0], transitions[0] + assert decision.after_facts["allowed"] is True + assert decision.actor_id == transition.actor_id == assignments[0].contributor_id + assert decision.project_id == project["id"] + assert (transition.from_status, transition.to_status) == ("ready", "claimed") + assert transition.event_payload["references"]["authorization_decision_id"] == decision.id + assert await session.get(AuditEvent, staged[0]) is None + + +async def test_manager_context_and_system_operator_override_are_distinct(task_client, monkeypatch): + project = await create_active_project(task_client) + task = await create_ready_task(task_client, project["id"]) + task_id = task["id"] + manager_context = await task_client.get( + f"/api/v1/projects/{project['id']}/tasks/{task_id}/work-context", + headers=auth_headers(), + ) + assert manager_context.status_code == 200, manager_context.text + assert manager_context.json()["lifecycle"]["next_actions"] == [] + contributor_context = await task_client.get( + f"/api/v1/tasks/{task_id}/work-context", headers=auth_headers() + ) + assert contributor_context.status_code == 403, contributor_context.text + owner = await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "assigned-contributor" + ) + claimed = await task_client.post(f"/api/v1/tasks/{task_id}/claim", headers=auth_headers()) + assert claimed.status_code == 200, claimed.text + + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + denied = await task_client.post( + f"/api/v1/operations/tasks/{task_id}/start", + headers=auth_headers(), + json={"reason": "Manager is not Operator"}, + ) + assert denied.status_code == 403, denied.text + set_dev_actor(monkeypatch, roles="admin", subject="explicit-operator") + admitted = await task_client.get("/api/v1/actors/me", headers=auth_headers()) + assert admitted.status_code == 200, admitted.text + operator_id = admitted.json()["actor_profile_id"] + denied = await task_client.post( + f"/api/v1/operations/tasks/{task_id}/start", + headers=auth_headers(), + json={"reason": "Token role is not authority"}, + ) + assert denied.status_code == 403, denied.text + + # Use the existing project fixture's bootstrap identity to issue a real + # Operator grant. This test does not claim to prove bootstrap provisioning. + async with db_session.get_session_factory()() as session: + control = await session.get(AuthorityControl, 1) + bootstrap_grant = await session.get(AdminRoleGrant, control.bootstrap_grant_id) + bootstrap_link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.actor_profile_id == bootstrap_grant.target_actor_profile_id, + ) + ) + bootstrap_subject, bootstrap_issuer = bootstrap_link.subject, bootstrap_link.issuer + set_dev_actor(monkeypatch, roles="viewer", subject=bootstrap_subject, issuer=bootstrap_issuer) + issued = await task_client.post( + "/api/v1/admin-role-grants", + headers=auth_headers(), + json={ + "target_actor_profile_id": operator_id, + "role": "operator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Assign operations responsibility", + }, + ) + assert issued.status_code == 201, issued.text + set_dev_actor(monkeypatch, roles="viewer", subject="explicit-operator") + normal = await task_client.post(f"/api/v1/tasks/{task_id}/start", headers=auth_headers()) + assert normal.status_code == 403, normal.text + for body in ({}, {"reason": " "}): + no_reason = await task_client.post( + f"/api/v1/operations/tasks/{task_id}/start", headers=auth_headers(), json=body + ) + assert no_reason.status_code == 422, no_reason.text + reason = "Operator verified assigned contributor started work" + started = await task_client.post( + f"/api/v1/operations/tasks/{task_id}/start", + headers=auth_headers(), + json={"reason": reason}, + ) + assert started.status_code == 200, started.text + async with db_session.get_session_factory()() as session: + row = await session.get(WorkstreamTask, task_id) + assert row.status == "in_progress" and row.assigned_to == owner["actor_profile_id"] + assignment = await session.get(TaskAssignment, claimed.json()["assignment"]["id"]) + assert assignment.contributor_id == owner["actor_profile_id"] + event = await session.scalar( + select(AuditEvent).where( + AuditEvent.entity_id == task_id, + AuditEvent.event_type == "TaskStartOverridden", + ) + ) + assert event is not None and event.actor_id == operator_id + assert event.reason == reason + decision = await session.get( + AuditEvent, event.event_payload["references"]["authorization_decision_id"] + ) + assert decision.action_id == "operations.task.start_override" + assert decision.matched_grant_id == issued.json()["resource_id"] diff --git a/backend/tests/checkers/test_effective_intake_rules.py b/backend/tests/checkers/test_effective_intake_rules.py new file mode 100644 index 000000000..754c99fd7 --- /dev/null +++ b/backend/tests/checkers/test_effective_intake_rules.py @@ -0,0 +1,69 @@ +"""Project intake rules over actual prepared ZIP bytes, not caller packet manifests. + +AUTH is an explicit port double here; canonical authority has separate tests. +This module proves materialization outcomes, not durable admission or Submission creation. +""" + +from dataclasses import replace + +import pytest + +from app.core.hashing import canonical_json_hash +from app.modules.artifacts.submission_materialization import PreparedBundleMaterializationService +from app.modules.checkers.compiler import compile_effective_project_submission_artifact_policy +from app.modules.checkers.effective_plan import compile_effective_pre_submission_execution_plan +from app.modules.checkers.pre_submit_execution import PreSubmissionResultStatus +from tests.test_default_pre_submit_execution import ( + _AllowAuthority, _CheckerExecution, _effective_policy, _request, +) + + +@pytest.mark.parametrize(("case", "failed_definition"), [ + ("complete", None), + ("missing_file", "policy.file.require"), + ("project_evidence", "policy.evidence.minimum"), + ("project_attestation", "policy.attestation.require"), + ("project_forbidden_file", "policy.artifact.forbid"), +]) +async def test_effective_project_rules_control_exact_prepared_contents( + tmp_path, case, failed_definition, +): + request, inspector, manager, preparation, catalogue = await _request( + tmp_path, + path="unexpected.txt" if case == "missing_file" else "task.toml", + extra_path="project-private.data" if case == "project_forbidden_file" else None, + ) + try: + policy = _effective_policy() + if case == "project_evidence": + addition = {"key": "build_log", "required": True} + policy["project_policy"] = {"required_evidence": [addition]} + policy["required_evidence"] = [*policy["required_evidence"], addition] + elif case == "project_attestation": + policy["project_policy"] = {"attestation_terms": ["project_confidentiality_confirmed"]} + policy["attestation_terms"] = [*policy["attestation_terms"], "project_confidentiality_confirmed"] + elif case == "project_forbidden_file": + addition = {"pattern": "project-private.data"} + policy["project_policy"] = {"forbidden_artifacts": [addition]} + policy["forbidden_artifacts"] = [*policy["forbidden_artifacts"], addition] + policy_hash = canonical_json_hash(policy) + compiled = compile_effective_project_submission_artifact_policy(policy, policy_hash) + plan = compile_effective_pre_submission_execution_plan( + lineage=replace( + request.effective_plan.lineage, effective_policy_hash=policy_hash, + pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, + ), + effective_policy=policy, compiled_bundle=compiled.compiled_bundle, catalogue=catalogue, + ) + result = await PreparedBundleMaterializationService( + authorization=_AllowAuthority(), preparation=preparation, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", + ).materialize_prepared_bundle(replace(request, effective_plan=plan)) + assert result.eligible is (failed_definition is None) + failed = [entry.definition_id for entry in result.entries + if entry.checker_execution_status == PreSubmissionResultStatus.FAILED.value] + assert failed == ([] if failed_definition is None else [failed_definition]) + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + finally: + await request.prepared_artifact.close() + manager.close() diff --git a/backend/tests/checkers/test_packet_schema.py b/backend/tests/checkers/test_packet_schema.py new file mode 100644 index 000000000..f59a7a8a8 --- /dev/null +++ b/backend/tests/checkers/test_packet_schema.py @@ -0,0 +1,83 @@ +"""Retained checker packet validation, not a retired Submission POST contract.""" + +import pytest +from pydantic import ValidationError + +from app.modules.checkers.schemas import PreSubmitCheckRequest +from tests.test_tasks import complete_submission_payload + + +def test_checker_packet_accepts_complete_input(): + payload = complete_submission_payload() + parsed = PreSubmitCheckRequest.model_validate({"submission": payload}) + assert parsed.submission.model_dump(by_alias=True) == payload + + +@pytest.mark.parametrize("field", [ + "contributor_id", + "version", + "status", + "locked_guide_version", + "locked_post_submit_checker_policy_id", + "locked_post_submit_checker_policy_version", + "locked_post_submit_checker_policy_hash", + "locked_post_submit_checker_policy_body", + "locked_review_policy_id", + "locked_review_policy_generation", + "locked_review_policy_hash", + "locked_revision_policy_id", + "locked_revision_policy_generation", + "locked_revision_policy_hash", + "locked_payment_policy_version", + "locked_guide_source_snapshot_id", + "locked_guide_source_snapshot_hash", + "locked_effective_project_submission_artifact_policy_id", + "locked_effective_project_submission_artifact_policy_hash", + "locked_pre_submit_checker_policy_id", + "locked_pre_submit_checker_bundle_hash", + "runtime_parameters", + "finalized_at", +]) +def test_checker_packet_rejects_each_client_owned_authority_or_lock_field(field): + payload = complete_submission_payload() + payload[field] = "client-controlled" + with pytest.raises(ValidationError) as rejected: + PreSubmitCheckRequest.model_validate({"submission": payload}) + assert [(error["loc"], error["type"]) for error in rejected.value.errors()] == [ + (("submission", field), "extra_forbidden"), + ] + + +@pytest.mark.parametrize(("collection", "field"), [ + ("artifact_hash_manifest", "locked_guide_version"), + ("evidence_items", "submission_id"), +]) +def test_checker_packet_rejects_nested_authority_injection(collection, field): + payload = complete_submission_payload() + payload[collection][0][field] = "client-controlled" + with pytest.raises(ValidationError) as rejected: + PreSubmitCheckRequest.model_validate({"submission": payload}) + assert [(error["loc"], error["type"]) for error in rejected.value.errors()] == [ + (("submission", collection, 0, field), "extra_forbidden"), + ] + + +@pytest.mark.parametrize(("field", "value"), [ + ("package_uri", "https://storage.example.test/package.tar?token=secret"), + ("evidence_items", "file:///home/worker/private/evidence.log"), + ("package_uri", "local://"), + ("evidence_items", "local://../private/evidence.log"), +]) +def test_checker_packet_rejects_unsafe_storage_references(field, value): + payload = complete_submission_payload() + if field == "evidence_items": + payload[field][0]["uri"] = value + expected_location = ("submission", field, 0, "uri") + else: + payload[field] = value + expected_location = ("submission", field) + with pytest.raises(ValidationError) as rejected: + PreSubmitCheckRequest.model_validate({"submission": payload}) + assert [(error["loc"], error["type"]) for error in rejected.value.errors()] == [ + (expected_location, "value_error"), + ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 67cc6284b..942b02181 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,9 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "b4bea4699ce986c73b023dd310a231609aaaaffb29dc14348e1d01e66faa7e74" +# 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 = "719fbcf4e617bf14b145e9ed67789de98ec349ee96af07154872ad62c261a44c" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/projects/guide_compilation/test_automatic_request.py b/backend/tests/projects/guide_compilation/test_automatic_request.py index 751a78b74..02e86fa7c 100644 --- a/backend/tests/projects/guide_compilation/test_automatic_request.py +++ b/backend/tests/projects/guide_compilation/test_automatic_request.py @@ -1,6 +1,7 @@ """Automatic request proof using real authorized source mutations and ART material.""" from tests.projects.guide_compilation.helpers import runtime_configuration +from tests.migration_fixtures import current_schema_revision from uuid import UUID @@ -392,7 +393,7 @@ def downgrade(): async with factory() as session: assert ( await session.scalar(text("select version_num from alembic_version")) - == "0016_guide_document_runtime" + == current_schema_revision() ) assert ( await session.scalar( diff --git a/backend/tests/submission_fixtures.py b/backend/tests/submission_fixtures.py new file mode 100644 index 000000000..39431791a --- /dev/null +++ b/backend/tests/submission_fixtures.py @@ -0,0 +1,74 @@ +"""Stored upstream prerequisites for checker/review-owner tests. + +These fixtures do not prove Submission creation or ART admission, return an +HTTP response, or make a hidden endpoint public. They seed a stored Submission +and exercise the existing finalization/enqueue owners for downstream tests. +""" + +from uuid import uuid4 + +from sqlalchemy import select + +from app.db import session as db_session +from app.modules.actors.models import ActorIdentityLink +from app.modules.tasks.models import EvidenceItem, Submission, TaskAssignment, WorkstreamTask +from app.modules.tasks.schemas import SubmissionCreate +from app.modules.tasks.service import TaskService +from app.modules.tasks.submission_composition import build_submission +from app.schemas.auth import ActorContext + + +async def seed_finalized_submission_for_checker_test( + task_id: str, payload: dict, *, predecessor_id: str | None = None, + raise_on_dispatch_failure: bool = True, +) -> str: + """Seed one upstream packet, then run real finalization and checker enqueue.""" + packet = SubmissionCreate.model_validate(payload) + submission_id = str(uuid4()) + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, task_id) + assert task is not None + assert task.status == ("needs_revision" if predecessor_id else "in_progress") + predecessor = await session.get(Submission, predecessor_id) if predecessor_id else None + if predecessor_id: + assert predecessor is not None and predecessor.task_id == task_id + assert predecessor.contributor_id == task.assigned_to + assignment = await session.scalar(select(TaskAssignment).where( + TaskAssignment.task_id == task_id, TaskAssignment.status == "active", + )) + assert assignment is not None and assignment.contributor_id == task.assigned_to + link = await session.scalar(select(ActorIdentityLink).where( + ActorIdentityLink.actor_profile_id == task.assigned_to, + )) + assert link is not None and link.status == "active" + actor = ActorContext( + actor_id=task.assigned_to, external_subject=link.subject, + external_issuer=link.issuer, roles=("worker",), claim_snapshot={}, + auth_source="dev_mock", is_dev_auth=True, + ) + service = TaskService(session) + await service._load_locked_task_context(task) + submission = build_submission( + submission_id=submission_id, task=task, contributor_id=task.assigned_to, + # Retained packets have no ART lineage group. Do not invent half + # of that group or claim this fixture proves admission-backed writes. + version=predecessor.version + 1 if predecessor else 1, summary=packet.summary, + worker_attestation=packet.worker_attestation, + package_uri=packet.package_uri, package_hash=packet.package_hash, + artifact_hash_manifest=[entry.model_dump() for entry in packet.artifact_hash_manifest], + supersedes_submission_id=predecessor_id, + evidence_items=[EvidenceItem( + id=str(uuid4()), submission_id=submission_id, type=item.type, + label=item.label, uri=item.uri, hash=item.hash, + size_bytes=item.size_bytes, metadata_json=item.metadata, + ) for item in packet.evidence_items], + ) + session.add(submission) + task.status = "submitted" + await session.flush() + await service._finalize_submission_for_evaluation(actor, task, submission) + await session.commit() + await service._enqueue_pre_review_gate_after_commit( + actor, submission_id, raise_on_failure=raise_on_dispatch_failure, + ) + return submission_id diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 99d9c9840..c1a5b1a56 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -86,6 +86,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0016_guide_document_runtime", "0015_guide_runtime_configuration", "0014_project_role_scope", "0013_compilation_request_origin", @@ -121,6 +122,20 @@ def test_fresh_database_matches_committed_manifest( assert actual == expected +def test_repeated_upgrade_head_preserves_current_database( + isolated_database_env: str, migration_lock +) -> None: + """An already-current database stays usable without recreation or data loss.""" + config = _alembic_config() + with migration_lock(): + before = asyncio.run(_database_snapshot(isolated_database_env)) + assert before["versions"] == [HEAD_REVISION] + command.upgrade(config, "head") + command.upgrade(config, "head") + after = asyncio.run(_database_snapshot(isolated_database_env)) + assert after == before + + def test_current_head_installs_submission_lineage_contract( isolated_database_env: str, migration_lock ) -> None: diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 33885f411..19b71881e 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,12 +439,17 @@ def test_openapi_documents_request_error_and_response_context() -> None: ) assert len(route_inventory) == 73 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "58e52a93a0f081691e5dff6f6226d2a45f843a3ddc9df10d560e83dc1ee9439a" + "23b90006282444310fe13a34fab947c8cff7284b288a2fe26e2ddb9780f8691f" ) assert len(protected_inventory) == 71 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "588b760470932011dc1d2c669e700891e31120df2674d50f60163032aa349ac8" + "286149aa75927259d4659eee97400b53e41623ae234a3cf1f5657647edfd269d" ) + assert "/api/v1/workers/me/profile" not in schema["paths"] + assert "post" not in schema["paths"]["/api/v1/tasks/{task_id}/submissions"] + assert "GET /api/v1/tasks/{task_id}/submissions" in protected_inventory + assert "POST /api/v1/operations/tasks/{task_id}/start" in protected_inventory + assert "GET /api/v1/projects/{project_id}/tasks/{task_id}/work-context" in protected_inventory assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set(schema["paths"]["/api/v1/auth/me"]["get"]["responses"]) service_actor_responses = schema["paths"]["/api/v1/service-actors"]["post"]["responses"] @@ -463,6 +468,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: if method in methods and "x-workstream-action-id" in operation } assert action_declarations == { + "POST /api/v1/tasks/{task_id}/claim": "task.claim", + "POST /api/v1/tasks/{task_id}/start": "task.start", + "GET /api/v1/tasks/{task_id}/work-context": "task.work_context.read", + "POST /api/v1/operations/tasks/{task_id}/start": "operations.task.start_override", + "GET /api/v1/projects/{project_id}/tasks/{task_id}/work-context": ( + "project.task.work_context.read" + ), "GET /api/v1/actors/me": "actor.profile.read_self", "PATCH /api/v1/actors/me": "actor.profile.update_self", "GET /api/v1/actors/me/authorization-context": ("actor.authorization_context.read"), diff --git a/backend/tests/test_artifact_bindings_db.py b/backend/tests/test_artifact_bindings_db.py index b966fd70e..e7e706339 100644 --- a/backend/tests/test_artifact_bindings_db.py +++ b/backend/tests/test_artifact_bindings_db.py @@ -26,6 +26,7 @@ from app.modules.tasks.models import Submission from app.modules.tasks.models import AuditEvent from app.modules.tasks.repository import TaskRepository +from app.modules.tasks.service import TaskService from app.api.deps.authorization import compose_hidden_submission_creation_command from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization import prepared as prepared_authorization @@ -197,8 +198,14 @@ async def get_task(self, task_id, **kwargs): del self, task_id, kwargs return task + async def validate_context(self, candidate): + # This isolated ART schema deliberately doubles the TASK owner. Full + # policy rejection is proved in task_authority/test_submission_policy.py. + assert candidate is task + monkeypatch.setattr(TaskRepository, "lock_submission_context", lock_context) monkeypatch.setattr(TaskRepository, "get_task", get_task) + monkeypatch.setattr(TaskService, "_load_locked_task_context", validate_context) async with _isolated_binding_schema(isolated_database_env) as (schema, factory): async with factory.begin() as seed: await _seed(seed, schema, art_request) @@ -388,6 +395,9 @@ def _wire_hidden_authority(monkeypatch: pytest.MonkeyPatch): async def lock_context(_self, _request): return context async def get_task(_self, _task_id, **_kwargs): return task + async def validate_context(_self, candidate): + # These are ART transaction tests, not a TASK policy certification. + assert candidate is task async def lock_actor(_self, link_id, actor_id): is_service = actor_id == service_actor_id return ( @@ -405,6 +415,7 @@ async def fixed_context(*_args, **_kwargs): return service monkeypatch.setattr(TaskRepository, "lock_submission_context", lock_context) monkeypatch.setattr(TaskRepository, "get_task", get_task) + monkeypatch.setattr(TaskService, "_load_locked_task_context", validate_context) monkeypatch.setattr(AdminAuthorizationRepository, "lock_request_actor", lock_actor) monkeypatch.setattr(AdminAuthorizationRepository, "find_active_project_role", find_role) monkeypatch.setattr( diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index cd46cd3e6..106dc6c8b 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -31,15 +31,8 @@ LifecycleAuditReferenceKind, ) from app.modules.audit.service import AuditService, LifecycleAuditParticipant -from app.modules.authorization.catalogue import ( - ACTION_DEFINITIONS, - ActionAvailability, - ActionId, - PermissionId, -) from app.modules.tasks.models import AuditEvent from tests.assertion_helpers import assert_secret_not_retained -from tests.authorization.catalogue_fixtures import AUDIT_ALLOWED_ACTION_VALUES @pytest.fixture @@ -123,83 +116,6 @@ def _authority_input(event_type: AuthorityEventType, **overrides) -> AuthorityAu return AuthorityAuditEventInput(**values) -def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> None: - denied = _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, - permission_id="artifact.binding.read", - action_id="artifact.binding.read", - denial_code="permission_not_granted", - ) - assert (denied.action_id, denied.permission_id) == (ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ) - with pytest.raises(ValidationError, match="action permission"): - _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, - permission_id="artifact.replica.read", - action_id="artifact.binding.read", - denial_code="permission_not_granted", - ) - with pytest.raises(ValidationError, match="new permission requires"): - _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, - permission_id="artifact.binding.read", - action_id=None, - denial_code="permission_not_granted", - ) - allowed_action_ids: set[ActionId] = set() - for definition in ACTION_DEFINITIONS: - if definition.availability is ActionAvailability.PLANNED: - with pytest.raises(ValidationError, match="planned action"): - _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, - permission_id=definition.permission_id, - action_id=definition.action_id, - ) - else: - allowed = _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, - permission_id=definition.permission_id, - action_id=definition.action_id, - ) - assert allowed.action_id is not None - allowed_action_ids.add(allowed.action_id) - assert {action.value for action in allowed_action_ids} == AUDIT_ALLOWED_ACTION_VALUES - artifact_allowed = _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, - permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, - action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, - after_facts={"allowed": True, "resource_context_digest": "sha256:" + "a" * 64}, - ) - assert artifact_allowed.after_facts["resource_context_digest"] == "sha256:" + "a" * 64 - with pytest.raises(TypeError, match="invalid authority audit input"): - _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, - permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, - action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, - after_facts={"allowed": True, "resource_context_digest": "not-a-digest"}, - ) - with pytest.raises(TypeError, match="invalid authority audit input"): - _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, - permission_id="artifact.binding.read", - action_id="unknown.action", - denial_code="permission_not_granted", - ) - event_id = uuid4() - with pytest.raises(ValidationError, match="action requires authorization decision"): - AuthorityAuditEventInput( - event_id=event_id, - event_type=AuthorityEventType.ADMIN_ROLE_GRANT_ISSUE_DENIED, - entity_type="admin_role_grant", - entity_id=str(uuid4()), - actor_ref_kind=ActorReferenceKind.SYSTEM_PRINCIPAL, - actor_ref="workstream:system:bootstrap", - request_id=uuid4(), - correlation_id=uuid4(), - permission_id=PermissionId.ACTOR_PROFILE_READ_SELF, - action_id=ActionId.ACTOR_PROFILE_READ_SELF, - reason="authorization_policy_denial", - denial_code="permission_not_granted", - ) def test_project_create_audit_event_binds_operation_to_future_project() -> None: @@ -1282,8 +1198,15 @@ def _lifecycle_input(**overrides) -> LifecycleAuditEventInput: return LifecycleAuditEventInput(**values) + + def test_lifecycle_input_covers_every_canonical_event_entity_pair() -> None: event_groups = { + LifecycleAuditEntityType.TASK: { + LifecycleAuditEventType.TASK_CLAIMED, + LifecycleAuditEventType.TASK_STARTED, + LifecycleAuditEventType.TASK_START_OVERRIDDEN, + }, LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY: { LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CREATED, LifecycleAuditEventType.REVIEW_ROUTED_TO_PREFERRED_REVIEWER, @@ -1332,6 +1255,7 @@ def test_lifecycle_input_covers_every_canonical_event_entity_pair() -> None: } assert set().union(*event_groups.values()) == set(LifecycleAuditEventType) entity_references = { + LifecycleAuditEntityType.TASK: LifecycleAuditReferenceKind.TASK, LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY: LifecycleAuditReferenceKind.REVIEW_QUEUE_ENTRY, LifecycleAuditEntityType.REVIEW_LEASE: LifecycleAuditReferenceKind.REVIEW_LEASE, LifecycleAuditEntityType.REVIEW: LifecycleAuditReferenceKind.REVIEW, @@ -1370,11 +1294,24 @@ def test_lifecycle_input_covers_every_canonical_event_entity_pair() -> None: ) elif event_type is LifecycleAuditEventType.COMPENSATION_AWARD_CREATED: references[LifecycleAuditReferenceKind.CONTRIBUTION_RECORD] = uuid4() + transition = {} + if entity_type is LifecycleAuditEntityType.TASK: + references.update({ + LifecycleAuditReferenceKind.ASSIGNMENT: uuid4(), + LifecycleAuditReferenceKind.AUTHORIZATION_DECISION: uuid4(), + }) + transition = { + "reason": LifecycleAuditReason.STATE_CHANGED, + "from_status": "ready" if event_type is LifecycleAuditEventType.TASK_CLAIMED else "claimed", + "to_status": "claimed" if event_type is LifecycleAuditEventType.TASK_CLAIMED else "in_progress", + "task_reason": "Explicit task operation", + } value = _lifecycle_input( entity_type=entity_type, entity_id=entity_id, event_type=event_type, references=references, + **transition, ) assert value.event_type is event_type diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index c3ba9dbe6..d975fcd5a 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -84,8 +84,8 @@ def _application_paths(app) -> set[str]: return paths -def test_legacy_submitter_eligibility_adapter_has_a_shrinking_static_allowlist() -> None: - """Confine the temporary bridge to its owner, lifecycle view, and intake gates.""" +def test_retired_submitter_eligibility_bridge_has_no_runtime_consumers() -> None: + """Do not reintroduce the removed self-activation authority path.""" app_root = Path(__file__).resolve().parents[1] / "app" compatibility_name = "LegacyWorkflowEligibilityCompatibility" consumers: set[str] = set() @@ -138,20 +138,8 @@ def test_legacy_submitter_eligibility_adapter_has_a_shrinking_static_allowlist() } ) - assert consumers == { - "modules/actors/service.py", - "modules/tasks/service.py", - } - assert sorted(compatibility_calls) == [ - ( - "_require_legacy_submitter_eligibility", - "get_active_submitter_eligibility", - ), - ("claim_task", "_require_legacy_submitter_eligibility"), - ("create_submission", "_require_legacy_submitter_eligibility"), - ("get_task_work_context", "get_active_submitter_eligibility"), - ("start_task", "_require_legacy_submitter_eligibility"), - ] + assert consumers == set() + assert compatibility_calls == [] def current_task_name() -> str: diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index e669517a4..374d02289 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -217,7 +217,6 @@ AuthorizationDenialCode, AuthorizationEvidenceUnavailable, HumanAuthorizationContext, - GuideSourceIngestResourceContext, IdentityLinkStatus, PreparedAuthorizationHandleInvalid, PreparedAuthorizationInput, @@ -1729,7 +1728,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) == 110 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 114 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -1808,8 +1807,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: 66, - ActionAvailability.PLANNED: 44, + ActionAvailability.ACTIVE: 71, + ActionAvailability.PLANNED: 43, } assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF with pytest.raises(ValueError, match="not active"): @@ -2425,9 +2424,8 @@ def test_art_custody_documentation_matches_the_independent_activation_fixture() assert "v0.1 baseline.\nThe REV transfer adds no migration." in operations assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations - assert ( - "73 PermissionIds, 112 ActionIds, 68 active actions, and\n44 planned actions" in operations - ) + assert "Catalogue entries and explicit runtime composition determine availability" in operations + assert "a planned action is not activated by its presence in the catalogue" in operations def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() -> None: @@ -4739,80 +4737,6 @@ async def test_prepared_admin_consume_reuses_exact_locked_grant_without_requery( assert len(evidence.events) == 1 -@pytest.mark.asyncio -async def test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant(): - context = _runtime_context() - assert isinstance(context, HumanAuthorizationContext) - session = _PreparedTestSession() - authorization, evidence = _runtime_service(context, session=session) - facts = _PreparedAdminFacts(context) - authorization._admin = facts # type: ignore[assignment] - prepared = PreparedAuthorizationService( - session, # type: ignore[arg-type] - context, - authorization, - facts, # type: ignore[arg-type] - ) - project_id = uuid4() - caller_input = PreparedAuthorizationInput( - idempotency_key=uuid4(), request_value={"project_id": str(project_id)} - ) - handle = await prepared.prepare( - ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, - caller_input, - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, - project_id=project_id, - ), - ) - assert (facts.calls, facts.grant_calls) == (1, 1) - assert facts.grant_requests == [ - ( - (context.actor_profile_id, PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST), - {"scope_project_id": project_id, "for_update": True}, - ) - ] - - def resource(scope_project_id: UUID) -> GuideSourceIngestResourceContext: - return GuideSourceIngestResourceContext( - resource_type="project", - resource_id=scope_project_id, - scope_project_id=scope_project_id, - guide_id=uuid4(), - guide_source_snapshot_id=uuid4(), - guide_source_item_id=uuid4(), - operation_identity="sha256:" + "b" * 64, - request_digest="sha256:" + "c" * 64, - sha256="sha256:" + "d" * 64, - byte_count=17, - media_type="application/octet-stream", - ) - - with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - handle, - ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, - caller_input, - resource(uuid4()), - ) - assert evidence.events == [] - decision = await prepared.consume( - handle, - ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, - caller_input, - resource(project_id), - ) - assert decision.allowed is True - assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT - assert decision.matched_grant_id == facts.grant_id - assert decision.matched_scope_project_id == project_id - assert (facts.calls, facts.grant_calls) == (1, 1) - assert len(evidence.events) == 1 - assert evidence.events[0].project_id == str(project_id) - assert evidence.events[0].after_facts is not None - assert evidence.events[0].after_facts["resource_context_digest"] == ( - decision.resource_context_digest - ) @pytest.mark.asyncio diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 3cef6f2ae..5945ca212 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -1654,6 +1654,28 @@ def test_finalization_partition_additions_are_exact_and_cannot_authorize_neighbo ) +def test_partition_accepts_only_exact_task_project_authority_targets() -> None: + """TASK authority registration cannot admit an unrelated neighboring owner.""" + expected = frozenset({ + "backend/app/modules/authorization/domain/task_authority.py", + "backend/app/modules/authorization/task_authorization.py", + "backend/app/modules/tasks/api/authorization.py", + "backend/app/modules/tasks/api/transition_audit.py", + "backend/app/modules/tasks/authorized_commands.py", + }) + assert ownership.TASK_PROJECT_AUTHORITY_TARGETS == expected + retained = "backend/app/core/config.py" + trusted = _partition([retained]) + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *expected})), trusted + ) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *expected, "backend/app/modules/tasks/extra.py"})), + trusted, + ) + + def test_partition_accepts_only_exact_cp05_authorization_targets() -> None: """CP05 registers five named targets without admitting another AUTH owner.""" expected = frozenset({ diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index 45a88bdf7..ee902274c 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -75,6 +75,7 @@ ) from app.modules.tasks.models import AuditEvent, EvidenceItem, Submission, WorkstreamTask from app.modules.tasks.schemas import SubmissionCreate +from tests.submission_fixtures import seed_finalized_submission_for_checker_test from tests.test_tasks import ( auth_headers, complete_guide_payload, @@ -83,7 +84,7 @@ create_policy_bundle_for_guide, create_started_task, load_post_submit_checker_policy, - seed_worker_profile, + seed_task_test_actor, set_dev_actor, ) from project_create_fixtures import ( @@ -2985,16 +2986,13 @@ async def test_locked_submission_checker_run_persists_results_and_allows_review( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") _, body = await get_submission_and_automatic_pre_review_run( - checker_client, created.json()["id"] + checker_client, created_id ) assert body["status"] == "completed" assert body["trigger_source"] == "submission_finalized" @@ -3023,7 +3021,7 @@ async def test_locked_submission_checker_run_persists_results_and_allows_review( }.issubset({result["checker_name"] for result in body["results"]}) listed = await checker_client.get( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), ) assert listed.status_code == 200, listed.text @@ -3032,11 +3030,11 @@ async def test_locked_submission_checker_run_persists_results_and_allows_review( async with db_session.get_session_factory()() as session: audit = await session.get(AuditEvent, body["audit_event_id"]) task = await session.get(WorkstreamTask, started_task["id"]) - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) checker_run = await session.get(CheckerRun, body["id"]) assert audit is not None assert audit.event_type == "checker_run_triggered" - assert audit.entity_id == created.json()["id"] + assert audit.entity_id == created_id assert audit.reason == "submission locked for automatic pre-review gate" assert audit.event_payload["submission_version"] == 1 assert audit.event_payload["trigger_source"] == "submission_finalized" @@ -3105,15 +3103,12 @@ async def test_database_rejects_missing_submission_post_submit_policy_context( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text async with db_session.get_session_factory()() as session: - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) assert submission is not None submission.locked_post_submit_checker_policy_id = None submission.locked_post_submit_checker_policy_version = None @@ -3124,11 +3119,11 @@ async def test_database_rejects_missing_submission_post_submit_policy_context( async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) runs = ( ( await session.execute( - select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + select(CheckerRun).where(CheckerRun.submission_id == created_id) ) ) .scalars() @@ -3137,7 +3132,7 @@ async def test_database_rejects_missing_submission_post_submit_policy_context( results = ( ( await session.execute( - select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + select(CheckerResult).where(CheckerResult.submission_id == created_id) ) ) .scalars() @@ -3157,15 +3152,12 @@ async def test_manual_checker_run_rejects_crossed_post_submit_policy_sidecar( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text async with db_session.get_session_factory()() as session: - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) assert submission is not None locked_body = dict(submission.locked_post_submit_checker_policy_body or {}) policy = await session.scalar( @@ -3186,7 +3178,7 @@ async def test_manual_checker_run_rejects_crossed_post_submit_policy_sidecar( assert before["checker_results"] set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") rejected = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "Retry after policy corruption"}, ) @@ -3196,107 +3188,24 @@ async def test_manual_checker_run_rejects_crossed_post_submit_policy_sidecar( assert await task_side_effect_snapshot(started_task["id"]) == before async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) assert task is not None and submission is not None assert task.locked_post_submit_checker_policy_body == locked_body assert submission.locked_post_submit_checker_policy_body == locked_body -async def test_submission_rejects_malformed_locked_post_submit_policy_body_without_side_effects( - checker_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(checker_client) - started_task = await create_started_task(checker_client, project["id"], monkeypatch) - - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - assert task is not None - corrupted_body = dict(task.locked_post_submit_checker_policy_body or {}) - corrupted_body["required_checkers"] = [ - "check_policy_context_present", - "check_evidence_present", - ] - task.locked_post_submit_checker_policy_body = corrupted_body - await session.commit() - - rejected = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - - assert rejected.status_code == 422 - assert rejected.json()["code"] == "task_locked_context_invalid" - assert rejected.json()["details"]["field"] == "locked_post_submit_checker_policy_body" - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - runs = ( - ( - await session.execute( - select(CheckerRun) - .join(Submission, CheckerRun.submission_id == Submission.id) - .where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - results = ( - ( - await session.execute( - select(CheckerResult) - .join(Submission, CheckerResult.submission_id == Submission.id) - .where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - audit_events = ( - ( - await session.execute( - select(AuditEvent).where(AuditEvent.entity_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - - assert task is not None - assert task.status == "in_progress" - assert submissions == [] - assert runs == [] - assert results == [] - assert "submission_created" not in {event.event_type for event in audit_events} - assert "submission_finalized" not in {event.event_type for event in audit_events} - assert "checker_run_triggered" not in {event.event_type for event in audit_events} - - async def test_database_rejects_mismatched_submission_post_submit_policy_context( checker_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text async with db_session.get_session_factory()() as session: - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) assert submission is not None submission.locked_post_submit_checker_policy_hash = "sha256:" + "0" * 64 with pytest.raises(IntegrityError): @@ -3304,11 +3213,11 @@ async def test_database_rejects_mismatched_submission_post_submit_policy_context async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) runs = ( ( await session.execute( - select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + select(CheckerRun).where(CheckerRun.submission_id == created_id) ) ) .scalars() @@ -3317,7 +3226,7 @@ async def test_database_rejects_mismatched_submission_post_submit_policy_context results = ( ( await session.execute( - select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + select(CheckerResult).where(CheckerResult.submission_id == created_id) ) ) .scalars() @@ -3338,24 +3247,20 @@ async def test_database_rejects_checker_run_with_another_tasks_submission( ) -> None: project = await create_active_project(checker_client) first_task = await create_started_task(checker_client, project["id"], monkeypatch) - first = await checker_client.post( - f"/api/v1/tasks/{first_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + first_id = await seed_finalized_submission_for_checker_test( + first_task["id"], complete_submission_payload(), ) second_task = await create_started_task( checker_client, project["id"], monkeypatch, subject="worker-two" ) - second = await checker_client.post( - f"/api/v1/tasks/{second_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + second_id = await seed_finalized_submission_for_checker_test( + second_task["id"], complete_submission_payload(), ) - assert first.status_code == second.status_code == 201 + assert first_id != second_id async with db_session.get_session_factory()() as session: checker_run = await session.scalar( - select(CheckerRun).where(CheckerRun.submission_id == first.json()["id"]) + select(CheckerRun).where(CheckerRun.submission_id == first_id) ) assert checker_run is not None checker_run.task_id = second_task["id"] @@ -3369,16 +3274,13 @@ async def test_locked_submission_checker_run_enforces_required_evidence_key( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text async with db_session.get_session_factory()() as session: evidence = await session.scalar( - select(EvidenceItem).where(EvidenceItem.submission_id == created.json()["id"]) + select(EvidenceItem).where(EvidenceItem.submission_id == created_id) ) assert evidence is not None evidence.metadata_json = {"policy_key": "other_evidence"} @@ -3387,7 +3289,7 @@ async def test_locked_submission_checker_run_enforces_required_evidence_key( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") body = await run_manual_checker_retry( checker_client, - created.json()["id"], + created_id, "evidence metadata repair retry", ) @@ -3405,15 +3307,12 @@ async def test_locked_submission_checker_run_enforces_project_attestation_terms( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text async with db_session.get_session_factory()() as session: - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, created_id) assert submission is not None submission.worker_attestation = ( "I attest this submission contains no confidential client data, credentials, secrets, " @@ -3425,7 +3324,7 @@ async def test_locked_submission_checker_run_enforces_project_attestation_terms( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") body = await run_manual_checker_retry( checker_client, - created.json()["id"], + created_id, "attestation repair retry", ) @@ -3446,26 +3345,23 @@ async def test_checker_run_retry_supersedes_previous_current_run( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") _, first = await get_submission_and_automatic_pre_review_run( - checker_client, created.json()["id"] + checker_client, created_id ) set_dev_actor(monkeypatch, roles="project_manager", subject="other-project-manager") wrong_manager_retry = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "wrong project manager retry"}, ) assert wrong_manager_retry.status_code == 404 wrong_manager_list = await checker_client.get( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), ) assert wrong_manager_list.status_code == 404 @@ -3489,7 +3385,7 @@ async def test_checker_run_retry_supersedes_previous_current_run( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") second = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "retry run"}, ) @@ -3498,7 +3394,7 @@ async def test_checker_run_retry_supersedes_previous_current_run( assert second.json()["attempt_number"] == 2 assert second.json()["supersedes_checker_run_id"] == first["id"] listed = await checker_client.get( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), ) assert listed.status_code == 200, listed.text @@ -3507,208 +3403,6 @@ async def test_checker_run_retry_supersedes_previous_current_run( assert listed.json()[1]["is_current_for_submission"] is True -async def test_duplicate_artifact_fails_before_submission_row( - checker_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(checker_client) - started_task = await create_started_task(checker_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["artifact_hash_manifest"].append( - { - "artifact": "answer.md", - "hash": "sha256:duplicate", - "size_bytes": 129, - "notes": "duplicate", - } - ) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - assert created.status_code == 422, created.text - detail = created.json() - assert detail["code"] == "pre_submission_checker_failed" - duplicate_result = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_evidence_integrity" - ) - assert duplicate_result["status"] == "failed" - assert duplicate_result["would_block_if_submitted"] is True - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - checker_runs = (await session.execute(select(CheckerRun))).scalars().all() - assert task is not None - assert task.status == "in_progress" - assert submissions == [] - assert checker_runs == [] - - -async def test_chunk8_missing_required_file_fails_pre_submit_without_submission( - checker_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(checker_client) - started_task = await create_started_task(checker_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["artifact_hash_manifest"] = [ - { - "artifact": "other.md", - "hash": "sha256:other-v1", - "size_bytes": 128, - "notes": "wrong artifact", - } - ] - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - assert created.status_code == 422, created.text - detail = created.json() - assert detail["code"] == "pre_submission_checker_failed" - required_files = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_required_files" - ) - assert required_files["status"] == "failed" - assert required_files["would_block_if_submitted"] is True - assert "missing required artifact files" in required_files["worker_message"] - - -async def test_chunk8_default_blocking_checker_survives_omitted_blocking_severities( - checker_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project_response = await checker_client.post( - "/api/v1/projects", - headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, - json={ - "name": "Empty Blocking Severity Project", - "slug": "empty-blocking-severity-project", - }, - ) - assert project_response.status_code == 201, project_response.text - project = project_response.json() - guide_payload = complete_guide_payload() - guide_response = await checker_client.post( - f"/api/v1/projects/{project['id']}/guides", - headers=auth_headers(), - json=guide_payload, - ) - assert guide_response.status_code == 201, guide_response.text - await create_policy_bundle_for_guide( - checker_client, - project["id"], - guide_response.json()["id"], - post_submit_required_checkers=[], - post_submit_blocking_severities=None, - ) - await seed_active_guide_for_downstream_test( - db_session.get_session_factory(), - project_id=project["id"], - guide_id=guide_response.json()["id"], - ) - started_task = await create_started_task(checker_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["artifact_hash_manifest"] = [ - { - "artifact": "other.md", - "hash": "sha256:other-v1", - "size_bytes": 128, - "notes": "wrong artifact", - } - ] - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - assert created.status_code == 422, created.text - detail = created.json() - assert detail["code"] == "pre_submission_checker_failed" - required_files = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_required_files" - ) - assert required_files["status"] == "failed" - assert required_files["would_block_if_submitted"] is True - - -async def test_chunk8_forbidden_file_blocks_without_worker_path_leakage( - checker_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(checker_client) - started_task = await create_started_task(checker_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["artifact_hash_manifest"].append( - { - "artifact": "secrets/.env", - "hash": "sha256:env-v1", - "size_bytes": 64, - "notes": "should be removed", - } - ) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - assert created.status_code == 422, created.text - detail = created.json() - assert detail["code"] == "pre_submission_checker_failed" - forbidden = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_forbidden_files" - ) - assert forbidden["status"] == "failed" - assert forbidden["would_block_if_submitted"] is True - assert ".env" not in forbidden["worker_message"] - assert "secrets/" not in forbidden["worker_message"] - assert "local://" not in forbidden["worker_message"] - - -async def test_chunk8_confidentiality_attestation_blocks_generic_text( - checker_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(checker_client) - started_task = await create_started_task(checker_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["worker_attestation"] = "ok" - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - assert created.status_code == 422, created.text - detail = created.json() - assert detail["code"] == "pre_submission_checker_failed" - attestation = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_confidentiality_attestation" - ) - assert attestation["status"] == "failed" - assert attestation["would_block_if_submitted"] is True - assert "confidentiality attestation" in attestation["worker_message"] - - async def test_chunk8_low_quality_generated_artifacts_warns_without_blocking( checker_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -3717,16 +3411,13 @@ async def test_chunk8_low_quality_generated_artifacts_warns_without_blocking( started_task = await create_started_task(checker_client, project["id"], monkeypatch) payload = complete_submission_payload() payload["summary"] = "Completed the proof evaluation with a placeholder note to revise." - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], payload, ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") _, body = await get_submission_and_automatic_pre_review_run( - checker_client, created.json()["id"] + checker_client, created_id ) assert body["routing_recommendation"] == "allow_review" assert body["outcome_source"] == "none" @@ -3740,7 +3431,7 @@ async def test_chunk8_low_quality_generated_artifacts_warns_without_blocking( assert low_quality["blocks_review"] is False -async def test_checker_caused_revision_resubmits_fixed_version_through_api( +async def test_checker_revision_routing_and_reads_for_retained_packet_versions( checker_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -3760,14 +3451,11 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( assert precheck_v1.status_code == 200, precheck_v1.text assert precheck_v1.json()["eligible_to_submit"] is True - v1 = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v1_payload, + v1_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v1_payload, ) - assert v1.status_code == 201, v1.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, v1_run = await get_submission_and_automatic_pre_review_run(checker_client, v1.json()["id"]) + _, v1_run = await get_submission_and_automatic_pre_review_run(checker_client, v1_id) assert v1_run["routing_recommendation"] == "needs_revision" assert v1_run["outcome_source"] == "auto_checker" low_quality = next( @@ -3782,7 +3470,7 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) - v1_submission = await session.get(Submission, v1.json()["id"]) + v1_submission = await session.get(Submission, v1_id) gate_events = ( ( await session.execute( @@ -3863,7 +3551,7 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( assert "outcome_source" not in worker_audit.text assert "review_decision_id" not in worker_audit.text - await seed_worker_profile("worker-two") + await seed_task_test_actor("worker-two") set_dev_actor(monkeypatch, roles="worker", subject="worker-two") denied_before = await task_side_effect_snapshot(started_task["id"]) denied_precheck = await checker_client.post( @@ -3889,7 +3577,7 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( headers=auth_headers(), ) assert denied_precheck.status_code == 404 - assert denied_submit.status_code == 404 + assert denied_submit.status_code == 405 assert denied_submissions.status_code == 404 assert denied_run.status_code == 404 assert denied_audit.status_code == 404 @@ -3906,30 +3594,25 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( ) assert precheck_v2.status_code == 200, precheck_v2.text assert precheck_v2.json()["eligible_to_submit"] is True - v2 = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v2_payload, + v2_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v2_payload, predecessor_id=v1_id, ) - assert v2.status_code == 201, v2.text - assert v2.json()["version"] == 2 - assert v2.json()["supersedes_submission_id"] == v1.json()["id"] set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") stale_run = await checker_client.post( - f"/api/v1/submissions/{v1.json()['id']}/checker-runs", + f"/api/v1/submissions/{v1_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "stale v1 retry"}, ) assert stale_run.status_code == 409 - _, v2_run = await get_submission_and_automatic_pre_review_run(checker_client, v2.json()["id"]) + _, v2_run = await get_submission_and_automatic_pre_review_run(checker_client, v2_id) assert v2_run["routing_recommendation"] == "allow_review" assert v2_run["outcome_source"] == "none" async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) - persisted_v1 = await session.get(Submission, v1.json()["id"]) - persisted_v2 = await session.get(Submission, v2.json()["id"]) + persisted_v1 = await session.get(Submission, v1_id) + persisted_v2 = await session.get(Submission, v2_id) task_events = ( ( await session.execute( @@ -3956,12 +3639,12 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( assert persisted_v2.version == 2 assert persisted_v2.supersedes_submission_id == persisted_v1.id task_transitions = {f"{event.from_status}->{event.to_status}" for event in task_events} - assert "needs_revision->submitted" in task_transitions + # Packet creation is seeded; only the retained evaluation owner runs here. assert "submitted->evaluation_pending" in task_transitions assert "evaluation_pending->review_pending" in task_transitions -async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( +async def test_retained_packet_setup_failure_stays_blocked_until_repaired( checker_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -4000,16 +3683,13 @@ async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( task.acceptance_criteria = None await session.commit() - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") _, body = await get_submission_and_automatic_pre_review_run( - checker_client, created.json()["id"] + checker_client, created_id ) assert body["routing_recommendation"] == "task_setup_blocked" assert body["outcome_source"] == "auto_checker" @@ -4087,7 +3767,7 @@ async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") retry = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "task setup repaired"}, ) @@ -4103,168 +3783,58 @@ async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( assert task.status == "review_pending" -async def test_chunk10_checker_trial_runs_sample_submissions_through_real_api( +async def test_retained_packet_checker_trial_exposes_only_role_visible_results( checker_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - trial_cases = [ - { - "slug": "chunk10-clean-packet", - "worker_subject": "chunk10-worker-clean", - "payload": complete_submission_payload(), - "create_status": 201, - "route": "allow_review", - "task_status": "review_pending", - "checker_name": "check_submission_packet", - "checker_status": "passed", - "worker_route": "allow_review", - }, - { - "slug": "chunk10-missing-required-file", - "worker_subject": "chunk10-worker-missing-file", - "payload": { - **complete_submission_payload(), - "artifact_hash_manifest": [ - { - "artifact": "other.md", - "hash": "sha256:other-v1", - "size_bytes": 128, - "notes": "wrong artifact", - } - ], - }, - "create_status": 422, - "route": "pre_submission_checker_failed", - "checker_name": "check_required_files", - "checker_status": "failed", - }, - { - "slug": "chunk10-forbidden-file-path", - "worker_subject": "chunk10-worker-forbidden-file", - "payload": { - **complete_submission_payload(), - "artifact_hash_manifest": [ - *complete_submission_payload()["artifact_hash_manifest"], - { - "artifact": "secrets/.env", - "hash": "sha256:env-v1", - "size_bytes": 64, - "notes": "must be removed", - }, - ], - }, - "create_status": 422, - "route": "pre_submission_checker_failed", - "checker_name": "check_forbidden_files", - "checker_status": "failed", - }, - { - "slug": "chunk10-weak-confidentiality", - "worker_subject": "chunk10-worker-attestation", - "payload": { - **complete_submission_payload(), - "worker_attestation": "ok", - }, - "create_status": 422, - "route": "pre_submission_checker_failed", - "checker_name": "check_confidentiality_attestation", - "checker_status": "failed", - }, - ] - - for case in trial_cases: - set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - project = await create_checker_trial_project(checker_client, case["slug"]) - started_task = await create_started_task( - checker_client, - project["id"], - monkeypatch, - subject=case["worker_subject"], - ) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=case["payload"], - ) - assert created.status_code == case["create_status"], created.text - if case["create_status"] == 422: - detail = created.json() - assert detail["code"] == case["route"] - target_result = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == case["checker_name"] - ) - assert target_result["status"] == case["checker_status"] - async with db_session.get_session_factory()() as session: - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - task = await session.get(WorkstreamTask, started_task["id"]) - assert submissions == [] - assert task is not None - assert task.status == "in_progress" - continue - - set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, manager_run = await get_submission_and_automatic_pre_review_run( - checker_client, - created.json()["id"], - ) - assert manager_run["routing_recommendation"] == case["route"] - target_result = next( - result - for result in manager_run["results"] - if result["checker_name"] == case["checker_name"] - ) - assert target_result["status"] == case["checker_status"] - - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - assert task is not None - assert task.status == case["task_status"] - - set_dev_actor(monkeypatch, roles="worker", subject=case["worker_subject"]) - worker_read = await checker_client.get( - f"/api/v1/checker-runs/{manager_run['id']}", - headers=auth_headers(), - ) - assert worker_read.status_code == 200, worker_read.text - worker_body = worker_read.json() - assert "routing_recommendation" not in worker_body - assert "outcome_source" not in worker_body - worker_result = next( - result - for result in worker_body["results"] - if result["checker_name"] == case["checker_name"] - ) - assert worker_result["status"] == case["checker_status"] - assert worker_result["metadata"] == {} - if case["route"] == "needs_revision": - assert worker_result["worker_message"] - assert worker_result["worker_suggested_fix"] - if case["checker_name"] == "check_forbidden_files": - assert ".env" not in worker_read.text - assert "secrets/" not in worker_read.text - assert "local://" not in worker_read.text + # Intake failures are owned by the actual-ZIP effective-intake tests. This + # trial starts with a stored packet and exercises real checker/read routes. + project = await create_checker_trial_project(checker_client, "retained-clean-packet") + started_task = await create_started_task( + checker_client, project["id"], monkeypatch, subject="trial-worker-clean", + ) + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), + ) + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + _, manager_run = await get_submission_and_automatic_pre_review_run( + checker_client, created_id, + ) + assert manager_run["routing_recommendation"] == "allow_review" + target_result = next( + result for result in manager_run["results"] + if result["checker_name"] == "check_submission_packet" + ) + assert target_result["status"] == "passed" + async with db_session.get_session_factory()() as session: + task = await session.get(WorkstreamTask, started_task["id"]) + assert task is not None and task.status == "review_pending" + set_dev_actor(monkeypatch, roles="worker", subject="trial-worker-clean") + worker_read = await checker_client.get( + f"/api/v1/checker-runs/{manager_run['id']}", headers=auth_headers(), + ) + assert worker_read.status_code == 200, worker_read.text + worker_body = worker_read.json() + assert "routing_recommendation" not in worker_body + assert "outcome_source" not in worker_body + worker_result = next( + result for result in worker_body["results"] + if result["checker_name"] == "check_submission_packet" + ) + assert worker_result["status"] == "passed" + assert worker_result["metadata"] == {} set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") project = await create_checker_trial_project( checker_client, - "chunk10-task-setup-defect", + "retained-task-setup-defect", required_checkers=["check_acceptance_criteria_present"], ) started_task = await create_started_task( checker_client, project["id"], monkeypatch, - subject="chunk10-worker-task-setup", + subject="trial-worker-task-setup", ) async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) @@ -4272,17 +3842,14 @@ async def test_chunk10_checker_trial_runs_sample_submissions_through_real_api( task.acceptance_criteria = None await session.commit() - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") _, blocked_run = await get_submission_and_automatic_pre_review_run( checker_client, - created.json()["id"], + created_id, ) assert blocked_run["routing_recommendation"] == "task_setup_blocked" setup_result = next( @@ -4298,7 +3865,7 @@ async def test_chunk10_checker_trial_runs_sample_submissions_through_real_api( assert task is not None assert task.status == "evaluation_pending" - set_dev_actor(monkeypatch, roles="worker", subject="chunk10-worker-task-setup") + set_dev_actor(monkeypatch, roles="worker", subject="trial-worker-task-setup") worker_blocked_read = await checker_client.get( f"/api/v1/checker-runs/{blocked_run['id']}", headers=auth_headers(), @@ -4322,9 +3889,9 @@ async def test_chunk10_checker_trial_runs_sample_submissions_through_real_api( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") retry = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), - json={"trigger_reason": "task setup repaired during Chunk 10 trial"}, + json={"trigger_reason": "task setup repaired during retained-packet trial"}, ) assert retry.status_code == 200, retry.text retry_body = retry.json() @@ -4344,14 +3911,11 @@ async def test_worker_can_read_only_worker_visible_checker_result_fields( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, run = await get_submission_and_automatic_pre_review_run(checker_client, created.json()["id"]) + _, run = await get_submission_and_automatic_pre_review_run(checker_client, created_id) set_dev_actor(monkeypatch, roles="worker", subject="worker-one") read = await checker_client.get( @@ -4397,7 +3961,7 @@ async def test_worker_can_read_only_worker_visible_checker_result_fields( assert all(result["worker_visible"] is True for result in body["results"]) listed = await checker_client.get( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), ) assert listed.status_code == 200, listed.text @@ -4428,14 +3992,11 @@ async def test_worker_cannot_see_hidden_checker_results( ) -> None: project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, run = await get_submission_and_automatic_pre_review_run(checker_client, created.json()["id"]) + _, run = await get_submission_and_automatic_pre_review_run(checker_client, created_id) async with db_session.get_session_factory()() as session: session.add( @@ -4443,7 +4004,7 @@ async def test_worker_cannot_see_hidden_checker_results( id="hidden-result", checker_run_id=run["id"], task_id=started_task["id"], - submission_id=created.json()["id"], + submission_id=created_id, checker_name="internal_hidden_checker", status="failed", severity="high", @@ -4510,19 +4071,18 @@ async def test_checker_endpoints_reject_unassigned_worker_and_fake_result_payloa headers=auth_headers(), json=payload, ) - assert fake_submission.status_code == 422 + # Packet creation is removed, not a schema-validated alternative to ART. + assert fake_submission.status_code == 405, fake_submission.text + assert "POST" not in fake_submission.headers["allow"] assert await task_side_effect_snapshot(started_task["id"]) == rejected_payload_snapshot - created = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + created_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - await get_submission_and_automatic_pre_review_run(checker_client, created.json()["id"]) + await get_submission_and_automatic_pre_review_run(checker_client, created_id) fake_run = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), json={ "trigger_reason": "manual checker dry run", @@ -4533,7 +4093,7 @@ async def test_checker_endpoints_reject_unassigned_worker_and_fake_result_payloa ) assert fake_run.status_code == 422 blank_reason = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": " "}, ) @@ -4541,13 +4101,13 @@ async def test_checker_endpoints_reject_unassigned_worker_and_fake_result_payloa set_dev_actor(monkeypatch, roles="worker", subject="worker-one") worker_run = await checker_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "worker tries to trigger"}, ) assert worker_run.status_code == 403 - await seed_worker_profile("worker-two") + await seed_task_test_actor("worker-two") set_dev_actor(monkeypatch, roles="worker", subject="worker-two") denied = await checker_client.post( f"/api/v1/tasks/{started_task['id']}/submission-precheck", @@ -4558,7 +4118,7 @@ async def test_checker_endpoints_reject_unassigned_worker_and_fake_result_payloa set_dev_actor(monkeypatch, roles="auditor", subject="auditor-subject") no_role_existing = await checker_client.get( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{created_id}/checker-runs", headers=auth_headers(), ) no_role_missing = await checker_client.get( @@ -4581,20 +4141,17 @@ async def test_stale_locked_submission_cannot_receive_checker_run( project = await create_active_project(checker_client) started_task = await create_started_task(checker_client, project["id"], monkeypatch) first_payload = complete_submission_payload() - first = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=first_payload, + first_id = await seed_finalized_submission_for_checker_test( + started_task["id"], first_payload, ) - assert first.status_code == 201, first.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") locked_first = await checker_client.post( - f"/api/v1/submissions/{first.json()['id']}/finalize", + f"/api/v1/submissions/{first_id}/finalize", headers=auth_headers(), ) assert locked_first.status_code == 200, locked_first.text first_runs = await checker_client.get( - f"/api/v1/submissions/{first.json()['id']}/checker-runs", + f"/api/v1/submissions/{first_id}/checker-runs", headers=auth_headers(), ) assert first_runs.status_code == 200, first_runs.text @@ -4607,16 +4164,13 @@ async def test_stale_locked_submission_cannot_receive_checker_run( set_dev_actor(monkeypatch, roles="worker", subject="worker-one") second_payload = complete_submission_payload("sha256:package-v2") second_payload["artifact_hash_manifest"][0]["hash"] = "sha256:answer-v2" - second = await checker_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=second_payload, + second_id = await seed_finalized_submission_for_checker_test( + started_task["id"], second_payload, predecessor_id=first_id, ) - assert second.status_code == 201, second.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") stale_run = await checker_client.post( - f"/api/v1/submissions/{first.json()['id']}/checker-runs", + f"/api/v1/submissions/{first_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "stale run"}, ) @@ -4625,7 +4179,7 @@ async def test_stale_locked_submission_cannot_receive_checker_run( assert "latest submission" in stale_run.json()["detail"] _, second_run = await get_submission_and_automatic_pre_review_run( - checker_client, second.json()["id"] + checker_client, second_id ) assert second_run["submission_version"] == 2 assert second_run["trigger_source"] == "submission_finalized" @@ -4637,7 +4191,7 @@ async def test_stale_locked_submission_cannot_receive_checker_run( async with db_session.get_session_factory()() as session: submissions = ( - (await session.execute(select(Submission).where(Submission.id == first.json()["id"]))) + (await session.execute(select(Submission).where(Submission.id == first_id))) .scalars() .all() ) diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 27bf16151..8bbe1b511 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -142,6 +142,8 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/checkers/post_submit/test_request.py", "tests/checkers/post_submit/test_requirement_dispositions.py", "tests/checkers/post_submit/test_result_contract.py", + "tests/checkers/test_packet_schema.py", + "tests/checkers/test_effective_intake_rules.py", "tests/test_checker_catalogue.py", "tests/test_checkers.py", "tests/test_default_pre_submit_execution.py", diff --git a/backend/tests/test_coverage_contract.py b/backend/tests/test_coverage_contract.py index b2d8b6bbe..76970ddb5 100644 --- a/backend/tests/test_coverage_contract.py +++ b/backend/tests/test_coverage_contract.py @@ -2,6 +2,7 @@ from decimal import Decimal import json +import os from pathlib import Path import subprocess import sys @@ -17,6 +18,63 @@ PEP695_INVALID = sys.version_info < (3, 12) +def test_sqlalchemy_async_coverage_preserves_source_line_custody(tmp_path: Path) -> None: + """Actual SQLAlchemy switches must not attribute resumed app lines to callers.""" + app_source = tmp_path / "measured_app.py" + app_source.write_text( + "def work():\n" + " before = 'ready'\n" + " await_only(asyncio.sleep(0))\n" + " after = 'resumed'\n" + " await_only(asyncio.sleep(0))\n" + " return before, after\n", + encoding="utf-8", + ) + caller_source = tmp_path / "measured_caller.py" + caller_source.write_text( + "\n" * 40 + "async def call():\n return await greenlet_spawn(work)\n", + encoding="utf-8", + ) + probe = """ +import asyncio +import json +from pathlib import Path +import sys +from coverage import Coverage +from sqlalchemy.util.concurrency import await_only, greenlet_spawn + +config, app, caller = map(Path, sys.argv[1:]) +namespace = dict(asyncio=asyncio, await_only=await_only, greenlet_spawn=greenlet_spawn) +for path in (app, caller): + exec(compile(path.read_text(), str(path), 'exec'), namespace) +coverage = Coverage(config_file=str(config), data_file=None, include=[str(app), str(caller)]) +assert coverage.get_option('run:concurrency') == ['thread', 'greenlet'] +coverage.start() +try: + result = asyncio.run(namespace['call']()) +finally: + coverage.stop() +data = coverage.get_data() +print(json.dumps(dict(result=result, app=sorted(data.lines(str(app)) or []), + caller=sorted(data.lines(str(caller)) or [])))) +""" + # A child owns the probe tracer; inherited pytest-cov must not start a second one. + env = { + key: value for key, value in os.environ.items() + if not key.startswith(("COV_CORE_", "COVERAGE_")) + } + completed = subprocess.run( + [sys.executable, "-c", probe, str(SCRIPTS.parent / "pyproject.toml"), + str(app_source), str(caller_source)], + env=env, capture_output=True, text=True, timeout=30, check=True, + ) + assert json.loads(completed.stdout) == { + "result": ["ready", "resumed"], + "app": [2, 3, 4, 5, 6], + "caller": [42], + } + + def write_json(path: Path, value: dict, *, canonical: bool = False) -> Path: text = json.dumps(value, indent=2, sort_keys=True) + "\n" if canonical else json.dumps(value) path.write_text(text, encoding="utf-8") diff --git a/backend/tests/test_review_lease_persistence.py b/backend/tests/test_review_lease_persistence.py index 231079e34..e5de0c12b 100644 --- a/backend/tests/test_review_lease_persistence.py +++ b/backend/tests/test_review_lease_persistence.py @@ -62,12 +62,12 @@ def review_lease_database_env( async def review_lease_client( review_lease_database_env: str, ) -> AsyncIterator[AsyncClient]: - """Create only canonical upstream facts through existing test helpers.""" + """Set up project/task APIs; stored-submission fixtures supply lease prerequisites.""" app = create_app() async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver" ) as client: - response = await client.get("/api/v1/auth/me", headers=auth_headers()) + response = await client.get("/api/v1/actors/me", headers=auth_headers()) assert response.status_code == 200, response.text async with db_session.get_session_factory()() as session: await grant_system_project_manager( diff --git a/backend/tests/test_review_queue_persistence.py b/backend/tests/test_review_queue_persistence.py index 30fba5a26..8f14d6015 100644 --- a/backend/tests/test_review_queue_persistence.py +++ b/backend/tests/test_review_queue_persistence.py @@ -33,6 +33,7 @@ from app.modules.tasks.models import Submission from project_create_fixtures import grant_system_project_manager, insert_historical_project from tests.test_checkers import get_submission_and_automatic_pre_review_run +from tests.submission_fixtures import seed_finalized_submission_for_checker_test from tests.test_tasks import ( auth_headers, complete_submission_payload, @@ -64,13 +65,13 @@ def review_database_env( @pytest.fixture async def review_client(review_database_env: str) -> AsyncIterator[AsyncClient]: - """Return an API client used only to create canonical upstream test facts.""" + """Return an API client for project/task setup and stored-lineage reads.""" app = create_app() async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver", ) as client: - admission = await client.get("/api/v1/auth/me", headers=auth_headers()) + admission = await client.get("/api/v1/actors/me", headers=auth_headers()) assert admission.status_code == 200, admission.text async with db_session.get_session_factory()() as session: await grant_system_project_manager( @@ -93,15 +94,11 @@ async def _reviewable_lineage( monkeypatch, subject="review-worker-two", ) - submission_response = await client.post( - f"/api/v1/tasks/{task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + task["id"], complete_submission_payload(), ) - assert submission_response.status_code == 201, submission_response.text - submission = submission_response.json() set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, checker = await get_submission_and_automatic_pre_review_run(client, submission["id"]) + submission, checker = await get_submission_and_automatic_pre_review_run(client, submission_id) assert checker["status"] == "completed" assert checker["routing_recommendation"] == "allow_review" return project, task, submission | {"checker_run_id": checker["id"]} @@ -112,17 +109,13 @@ async def _additional_reviewable_submission( project: dict, monkeypatch: pytest.MonkeyPatch, ) -> tuple[dict, dict]: - """Create another exact task/submission/checker lineage in one project.""" + """Seed another stored submission and evaluate it for queue-owner tests.""" task = await create_started_task(client, project["id"], monkeypatch) - submission_response = await client.post( - f"/api/v1/tasks/{task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + task["id"], complete_submission_payload(), ) - assert submission_response.status_code == 201, submission_response.text - submission = submission_response.json() set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, checker = await get_submission_and_automatic_pre_review_run(client, submission["id"]) + submission, checker = await get_submission_and_automatic_pre_review_run(client, submission_id) assert checker["status"] == "completed" assert checker["routing_recommendation"] == "allow_review" return task, submission | {"checker_run_id": checker["id"]} diff --git a/backend/tests/test_submission_archive.py b/backend/tests/test_submission_archive.py index 3cfc0773c..ea1d9f747 100644 --- a/backend/tests/test_submission_archive.py +++ b/backend/tests/test_submission_archive.py @@ -86,6 +86,15 @@ def test_collision_and_ancestry_confusion_fail_closed(entries: dict[str, bytes]) rejection(archive_bytes(entries), SubmissionArchiveFailureCode.COLLISION) +def test_duplicate_physical_member_is_rejected_before_materialization() -> None: + output = BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("answer.md", b"first") + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr("answer.md", b"replacement") + rejection(output.getvalue(), SubmissionArchiveFailureCode.COLLISION) + + def test_symlink_entry_is_rejected() -> None: output = BytesIO() info = zipfile.ZipInfo("link") diff --git a/backend/tests/test_submission_composition.py b/backend/tests/test_submission_composition.py index 9f2058aef..06e438f43 100644 --- a/backend/tests/test_submission_composition.py +++ b/backend/tests/test_submission_composition.py @@ -1,6 +1,7 @@ """Focused behavior proof for hidden admission-backed Submission composition.""" from types import SimpleNamespace +from unittest.mock import AsyncMock from uuid import uuid4 import pytest @@ -116,6 +117,26 @@ async def test_human_lifecycle_denial_precedes_task_state( await service.create(request) +@pytest.mark.asyncio +async def test_foreign_contributor_denial_precedes_task_lookup(): + request = _request() + context = HumanAuthorizationContext( + actor_profile_id=uuid4(), actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), correlation_id=uuid4(), + ) + service = TaskSubmissionCreationService( + _Session(), authorization=PreparedSubmissionCreationAuthorization(object(), context), + admissions=None, + ) + service._repository = SimpleNamespace( + lock_submission_context=lambda value: pytest.fail("foreign task was inspected"), + ) + with pytest.raises(SubmissionCreationUnavailable): + await service.create(request) + + @pytest.mark.asyncio async def test_command_orders_authority_task_art_persistence_and_final_consumption(): request = _request() @@ -146,9 +167,14 @@ async def get_task(self, task_id): return _task() async def add_submission(self, submission): events.append(("persist", submission.version)) service._repository = Repository() + service._contexts = SimpleNamespace( + _load_locked_task_context=AsyncMock( + side_effect=lambda task: events.append(("policy", task.id)), + ), + ) result = await service.create(request) assert [event[0] for event in events] == [ - "authorize", "task", "prepare", "persist", "art", "final" + "authorize", "task", "policy", "prepare", "persist", "art", "final" ] assert result.submission_version == 1 @@ -196,6 +222,8 @@ async def get_task(self, task_id): return _task() async def add_submission(self, submission): persisted.append(submission) service._repository = Repository() + # This test isolates authority sequencing, not policy validation behavior. + service._contexts = SimpleNamespace(_load_locked_task_context=AsyncMock()) with pytest.raises(SubmissionCreationUnavailable): await service.create(request) assert events == [revocation] @@ -231,6 +259,8 @@ async def get_task(self, task_id): return _task() async def add_submission(self, submission): persisted.append(submission) service._repository = Repository() + # This test isolates ART result validation, not policy validation behavior. + service._contexts = SimpleNamespace(_load_locked_task_context=AsyncMock()) with pytest.raises(RuntimeError, match="exact binding facts"): await service.create(request) assert events == ["authorize", "prepare", "art"] @@ -291,6 +321,8 @@ async def get_task(self, task_id): return _task() async def add_submission(self, submission): seen.update(submission=submission) service._repository = Repository() + # This test isolates predecessor propagation, not policy validation behavior. + service._contexts = SimpleNamespace(_load_locked_task_context=AsyncMock()) result = await service.create(request) assert result.submission_version == 2 assert seen["submission"].supersedes_submission_id == str(predecessor.submission_id) diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 9a5ed3168..dd75a6d5c 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -18,15 +18,15 @@ from httpx import ASGITransport, AsyncClient from sqlalchemy import func, inspect, select, text, update from sqlalchemy.dialects import postgresql -from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] - AsyncConnection, AsyncSession, - create_async_engine, ) from sqlalchemy.schema import CreateIndex from projects.guide_fixtures import complete_guide_payload +from auth_concurrency_support import wait_for_named_database_lock +from tests.submission_fixtures import seed_finalized_submission_for_checker_test from app.adapters.auth.dev import actor_id_from_external_identity from app.core.config import get_settings @@ -35,6 +35,7 @@ from app.db import models as db_models from app.db import session as db_session from app.db.base import Base +from app.db.errors import integrity_constraint_name from app.main import create_app from app.modules.actors.models import ( ActorIdentityLink, @@ -42,13 +43,6 @@ LegacyActorIdentity, LegacyWorkflowEligibility, ) -from app.modules.actors.schemas import LegacyWorkflowEligibilityActivationRequest -from app.modules.actors.service import ( - ActiveHumanWriteActorRequired, - ActorService, - CanonicalWriteActorUnavailable, -) -from app.modules.checkers.service import CheckerService from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, GuideSourceSnapshot, @@ -85,10 +79,9 @@ ) from committed_guide_fixtures import create_compiled_report_fixture from app.modules.tasks.repository import TaskRepository -from app.modules.tasks.schemas import SubmissionCreate, TaskCreate +from app.modules.tasks.submission_composition import build_submission +from app.modules.tasks.schemas import TaskCreate from app.modules.tasks.service import ( - ActiveContributorRequired, - ContributorIdentityUnavailable, TaskLockedContextInvalid, TaskService, TaskServiceError, @@ -351,48 +344,6 @@ async def test_task_repository_delegates_audit_persistence() -> None: repository._audit_repository.list_audit_events.assert_awaited_once_with("task", "task-1") -async def test_task_contributor_revalidation_maps_failures_and_rolls_back() -> None: - actor = ActorContext( - actor_id=actor_id("write-actor"), - external_subject="write-actor", - external_issuer="flow-test", - roles=("worker",), - claim_snapshot={}, - auth_source="dev_mock", - is_dev_auth=True, - ) - session = MagicMock(spec=AsyncSession) - session.rollback = AsyncMock() - service = TaskService(session) - service._actors.require_active_human_write_actor = AsyncMock(return_value=None) - - assert await service._require_active_contributor(actor) is None - session.rollback.assert_not_awaited() - - cases = ( - ( - ActiveHumanWriteActorRequired("inactive"), - ActiveContributorRequired, - "active_contributor_required", - ), - ( - CanonicalWriteActorUnavailable("missing"), - ContributorIdentityUnavailable, - "contributor_identity_unavailable", - ), - ( - OperationalError("select", {}, RuntimeError("database unavailable")), - ContributorIdentityUnavailable, - "contributor_identity_unavailable", - ), - ) - for source_error, expected_error, code in cases: - service._actors.require_active_human_write_actor = AsyncMock(side_effect=source_error) - with pytest.raises(expected_error) as failure: - await service._require_active_contributor(actor) - assert failure.value.code == code - - assert session.rollback.await_count == len(cases) def task_service_actor(*roles: str) -> ActorContext: @@ -453,36 +404,23 @@ async def test_task_service_read_contexts_preserve_visibility_and_operator_scope task.id = "task-1" task.created_by = actor.actor_id context = MagicMock(name="locked_context") - eligibility = MagicMock(name="legacy_eligibility") task_response = MagicMock(name="task_response") - work_response = MagicMock(name="work_response") requirements_response = MagicMock(name="requirements_response") locked_response = MagicMock(name="locked_response") service._get_task = AsyncMock(return_value=task) service._ensure_task_visible = AsyncMock() service._load_locked_task_context = AsyncMock(return_value=context) - service._legacy_workflow_eligibility.get_active_submitter_eligibility = AsyncMock( - return_value=eligibility - ) service._task_response = MagicMock(return_value=task_response) - service._work_context_response = MagicMock(return_value=work_response) service._submission_requirements_response = MagicMock(return_value=requirements_response) service._locked_context_response = MagicMock(return_value=locked_response) assert await service.get_task(actor, task.id) is task_response - assert await service.get_task_work_context(actor, task.id) is work_response assert await service.get_task_submission_requirements(actor, task.id) is requirements_response assert await service.get_task_locked_context(actor, task.id) is locked_response - assert service._get_task.await_count == 4 - assert service._ensure_task_visible.await_count == 3 - assert service._load_locked_task_context.await_count == 3 - service._work_context_response.assert_called_once_with( - actor, - task, - context, - has_active_submitter_eligibility=False, - ) + assert service._get_task.await_count == 3 + assert service._ensure_task_visible.await_count == 2 + assert service._load_locked_task_context.await_count == 2 service._submission_requirements_response.assert_called_once_with(task, context) service._locked_context_response.assert_called_once_with(task, context) @@ -543,44 +481,6 @@ async def test_task_service_screen_and_release_own_transaction_boundaries() -> N assert session.refresh.await_args_list[1].args == (screened_task,) -async def test_task_service_contributor_start_uses_exact_active_assignment() -> None: - actor = task_service_actor("worker") - session = MagicMock(spec=AsyncSession) - session.commit = AsyncMock() - session.refresh = AsyncMock() - service = TaskService(session) - task = MagicMock(spec=WorkstreamTask) - task.id = "task-1" - task.status = "claimed" - assignment = MagicMock(spec=TaskAssignment) - assignment.id = "assignment-1" - assignment.contributor_id = actor.actor_id - response = MagicMock(name="task_response") - service._get_task = AsyncMock(return_value=task) - service._ensure_transition_allowed = MagicMock() - service._repo.get_active_assignment = AsyncMock(return_value=assignment) - service._require_legacy_submitter_eligibility = AsyncMock(return_value=MagicMock()) - service._change_task_status = AsyncMock() - service._task_response = MagicMock(return_value=response) - - result = await service.start_task(actor, task.id, "starting work") - - assert result is response - service._require_legacy_submitter_eligibility.assert_awaited_once_with(actor) - service._change_task_status.assert_awaited_once_with( - actor, - task, - "in_progress", - "starting work", - event_payload={ - "assignment_id": assignment.id, - "contributor_id": actor.actor_id, - "operator_override": False, - }, - event_type="task_status_changed", - ) - session.commit.assert_awaited_once_with() - session.refresh.assert_awaited_once_with(task) async def test_task_service_finalize_requeues_locked_latest_submission( @@ -864,7 +764,7 @@ async def task_client(task_database_env: str) -> AsyncIterator[AsyncClient]: transport=ASGITransport(app=app), base_url="http://testserver", ) as client: - admission = await client.get("/api/v1/auth/me", headers=auth_headers()) + admission = await client.get("/api/v1/actors/me", headers=auth_headers()) assert admission.status_code == 200, admission.text async with db_session.get_session_factory()() as session: await grant_system_project_manager( @@ -928,25 +828,6 @@ def actor_id(subject: str, issuer: str = "flow-test") -> str: return actor_id_from_external_identity(issuer, subject) -async def fetch_legacy_actor_rows( - subject: str, - issuer: str = "flow-test", -) -> tuple[LegacyActorIdentity | None, list[LegacyWorkflowEligibility]]: - """Load non-authoritative compatibility rows for assertions.""" - expected_actor_id = actor_id(subject, issuer) - async with db_session.get_session_factory()() as session: - identity = await session.get(LegacyActorIdentity, expected_actor_id) - profiles = ( - await session.scalars( - select(LegacyWorkflowEligibility) - .where(LegacyWorkflowEligibility.actor_id == expected_actor_id) - .order_by( - LegacyWorkflowEligibility.profile_type.asc(), - LegacyWorkflowEligibility.scope_type.asc(), - ) - ) - ).all() - return identity, list(profiles) def sha256_hash(seed: str) -> str: @@ -1349,8 +1230,7 @@ async def create_started_task( ) -> dict: set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") ready_task = await create_ready_task(client, project_id, payload) - await seed_worker_profile(subject) - set_dev_actor(monkeypatch, roles="worker", subject=subject) + await admit_and_grant_project_submitter(client, monkeypatch, project_id, subject) claim = await client.post( f"/api/v1/tasks/{ready_task['id']}/claim", headers=auth_headers(), @@ -1363,9 +1243,40 @@ async def create_started_task( json={"reason": "start"}, ) assert start.status_code == 200, start.text + # Claim/start above prove grant-only authority. Downstream retained + # checker/detail routes still expect this token role until their cutover. + set_dev_actor(monkeypatch, roles="worker", subject=subject) return start.json() +async def admit_and_grant_project_submitter( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch, project_id: str, subject: str, +) -> dict: + """Give a role-free contributor explicit authority for exactly one project.""" + set_dev_actor(monkeypatch, roles="viewer", subject=subject) + admitted = await client.get("/api/v1/actors/me", headers=auth_headers()) + assert admitted.status_code == 200, admitted.text + actor_profile_id = admitted.json()["actor_profile_id"] + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + response = await client.post( + f"/api/v1/projects/{project_id}/role-grants", + headers=auth_headers(), + json={ + "target_actor_profile_id": actor_profile_id, + "role": "submitter", + "qualification": { + "skills_snapshot": {"availability": "unavailable", "reference_ids": [], "unavailable_reason": "no_record"}, + "reputation_snapshot": {"availability": "unavailable", "reference_ids": [], "unavailable_reason": "no_record"}, + "prior_project_work_refs": [], "external_expertise_refs": [], + }, + "reason": "Explicit project assignment for task behavior tests", + }, + ) + assert response.status_code == 201, response.text + set_dev_actor(monkeypatch, roles="viewer", subject=subject) + return {"actor_profile_id": actor_profile_id, "grant_id": response.json()["id"]} + + def expected_worker_requester_provenance(subject: str = "worker-one") -> dict[str, str]: """Return the queue-safe requester provenance for a seeded worker actor.""" return { @@ -1381,7 +1292,8 @@ def hold_pre_review_enqueue(*, checker_run_id: str, requester_provenance: dict) return f"held:{checker_run_id}" -async def seed_worker_profile(subject: str, *, skill_tags: list[str] | None = None) -> str: +async def seed_task_test_actor(subject: str, *, stored_role: str = "worker") -> str: + """Seed identity facts for row/read tests; never seed eligibility or a grant.""" worker_actor_id = actor_id(subject) async with db_session.get_session_factory()() as session: session.add_all( @@ -1409,52 +1321,18 @@ async def seed_worker_profile(subject: str, *, skill_tags: list[str] | None = No external_issuer="flow-test", display_name=subject.replace("-", " ").title(), email=f"{subject}@example.test", - last_seen_roles=["worker"], + last_seen_roles=[stored_role], last_claim_snapshot={"seeded_for_task_test": True}, auth_source="dev_mock", is_dev_auth=True, ), - LegacyWorkflowEligibility( - id=str(uuid4()), - actor_id=worker_actor_id, - profile_type="worker", - status="active", - skill_tags=skill_tags or ["stem"], - scope_type="global", - scope_id="global", - profile_metadata={"seeded_for_task_test": True}, - ), + ] ) await session.commit() return worker_actor_id -async def _wait_for_task_database_lock( - database_url: str, - application_name: str, -) -> None: - """Wait until one named race participant is blocked on a PostgreSQL lock.""" - engine = create_async_engine(database_url) - try: - async with engine.connect() as connection: - for _ in range(5000): - waiting = await connection.scalar( - text( - "select exists(select 1 from pg_stat_activity where " - "application_name = :application_name " - "and wait_event_type = 'Lock')" - ), - {"application_name": application_name}, - ) - if waiting: - return - await asyncio.sleep(0) - finally: - await engine.dispose() - raise AssertionError(f"{application_name} never reached the PostgreSQL lock") - - async def _submission_context_request_for_started_task( task_id: str, contributor_id: str, @@ -1525,12 +1403,13 @@ async def test_task_repository_postgresql_submission_context_state_matrix( hold_pre_review_enqueue, ) set_dev_actor(monkeypatch, roles="worker", subject=subject) - submission_response = await task_client.post( - f"/api/v1/tasks/{task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + task["id"], complete_submission_payload(), ) - assert submission_response.status_code == 201, submission_response.text + submission_response = await task_client.get( + f"/api/v1/submissions/{submission_id}", headers=auth_headers(), + ) + assert submission_response.status_code == 200, submission_response.text predecessor = submission_response.json() async with db_session.get_session_factory()() as session: @@ -1580,7 +1459,7 @@ async def test_task_repository_postgresql_submission_context_state_matrix( await TaskRepository(session).lock_submission_context(revision_request) replacement_subject = "worker-submission-context-replacement" - replacement_contributor_id = await seed_worker_profile(replacement_subject) + replacement_contributor_id = await seed_task_test_actor(replacement_subject) async with db_session.get_session_factory()() as session: await session.execute( update(TaskAssignment) @@ -1636,7 +1515,7 @@ async def test_task_repository_postgresql_submission_context_lock_serializes_rac contender_call = asyncio.create_task( TaskRepository(contender).lock_submission_context(request) ) - await _wait_for_task_database_lock(task_database_env, contender_name) + await wait_for_named_database_lock(task_database_env, contender_name) assert not contender_call.done() await holder.rollback() contender_facts = await contender_call @@ -1650,603 +1529,8 @@ async def test_task_repository_postgresql_submission_context_lock_serializes_rac await contender.close() -async def _task_contributor_race_snapshot( - connection: AsyncConnection, - task_id: str, -) -> dict[str, object]: - """Capture every task-owned write surface relevant to contributor races.""" - task = ( - await connection.execute( - text("select status, assigned_to from workstream_tasks where id = :task_id"), - {"task_id": task_id}, - ) - ).one() - assignments = ( - await connection.execute( - text( - "select id, contributor_id, assigned_by, status, accepted_at, released_at " - "from task_assignments where task_id = :task_id order by id" - ), - {"task_id": task_id}, - ) - ).all() - submissions = ( - await connection.execute( - text( - "select id, contributor_id, version, status, locked_at " - "from submissions where task_id = :task_id order by version" - ), - {"task_id": task_id}, - ) - ).all() - evidence_count = await connection.scalar( - text( - "select count(*) from evidence_items evidence " - "join submissions submission on submission.id = evidence.submission_id " - "where submission.task_id = :task_id" - ), - {"task_id": task_id}, - ) - checker_run_count = await connection.scalar( - text("select count(*) from checker_runs where task_id = :task_id"), - {"task_id": task_id}, - ) - checker_result_count = await connection.scalar( - text("select count(*) from checker_results where task_id = :task_id"), - {"task_id": task_id}, - ) - audit_events = ( - await connection.execute( - text( - "select id, event_type, from_status, to_status, actor_id, event_payload " - "from audit_events where entity_type = 'task' and entity_id = :task_id " - "order by created_at, id" - ), - {"task_id": task_id}, - ) - ).all() - idempotency_count = await connection.scalar( - text("select count(*) from authority_idempotency_records") - ) - return { - "task": tuple(task), - "assignments": [tuple(row) for row in assignments], - "submissions": [tuple(row) for row in submissions], - "evidence_count": evidence_count, - "checker_run_count": checker_run_count, - "checker_result_count": checker_result_count, - "audit_events": [tuple(row) for row in audit_events], - "idempotency_count": idempotency_count, - } - - -async def _read_task_contributor_race_snapshot( - database_url: str, - task_id: str, -) -> dict[str, object]: - engine = create_async_engine(database_url) - try: - async with engine.connect() as connection: - return await _task_contributor_race_snapshot(connection, task_id) - finally: - await engine.dispose() - - -async def _run_contributor_lifecycle_write( - database_url: str, - *, - actor_profile_id: str, - identity_link_id: str, - transition: str, - task_id: str, - application_name: str, - entered: asyncio.Event, - locked: asyncio.Event | None = None, - release: asyncio.Event | None = None, - observe_task_after_lock: bool = False, -) -> dict[str, object] | None: - """Apply one canonical-order lifecycle write in an independent transaction.""" - engine = create_async_engine(database_url) - observed: dict[str, object] | None = None - try: - async with engine.begin() as connection: - await connection.execute( - text("select set_config('application_name', :name, true)"), - {"name": application_name}, - ) - entered.set() - await connection.execute( - text("select id from actor_profiles where id = :id for update"), - {"id": actor_profile_id}, - ) - await connection.execute( - text("select id from actor_identity_links where id = :id for update"), - {"id": identity_link_id}, - ) - if locked is not None: - locked.set() - if release is not None: - await release.wait() - if observe_task_after_lock: - observed = await _task_contributor_race_snapshot(connection, task_id) - - if transition == "suspend": - await connection.execute( - text( - "update actor_profiles set status = 'suspended', " - "suspended_by = :actor_id, suspended_at = clock_timestamp(), " - "suspension_reason = 'contributor lock race' where id = :actor_id" - ), - {"actor_id": actor_profile_id}, - ) - elif transition == "deactivate": - await connection.execute( - text( - "update actor_profiles set status = 'deactivated', " - "deactivated_by = :actor_id, deactivated_at = clock_timestamp(), " - "deactivation_reason = 'contributor lock race' where id = :actor_id" - ), - {"actor_id": actor_profile_id}, - ) - else: - assert transition == "revoke_link" - await connection.execute( - text( - "update actor_identity_links set status = 'revoked', " - "revoked_by = :actor_id, revoked_at = clock_timestamp(), " - "revoked_reason = 'contributor lock race' where id = :link_id" - ), - {"actor_id": actor_profile_id, "link_id": identity_link_id}, - ) - return observed - finally: - await engine.dispose() - - -async def _run_task_contributor_write( - database_url: str, - *, - actor: ActorContext, - task_id: str, - operation: str, - application_name: str, - entered: asyncio.Event, -) -> object: - """Run one task write in its own named PostgreSQL session.""" - engine = create_async_engine(database_url) - try: - async with AsyncSession(engine, expire_on_commit=False) as session: - await session.execute( - text("select set_config('application_name', :name, true)"), - {"name": application_name}, - ) - entered.set() - service = TaskService(session) - if operation == "claim": - return await service.claim_task(actor, task_id, "contributor lock race") - assert operation == "submission" - return await service.create_submission( - actor, - task_id, - SubmissionCreate.model_validate(complete_submission_payload()), - ) - finally: - await engine.dispose() - - -async def _read_contributor_lifecycle_state( - database_url: str, - actor_profile_id: str, - identity_link_id: str, -) -> tuple[str, str]: - engine = create_async_engine(database_url) - try: - async with engine.connect() as connection: - profile_status = await connection.scalar( - text("select status from actor_profiles where id = :id"), - {"id": actor_profile_id}, - ) - link_status = await connection.scalar( - text("select status from actor_identity_links where id = :id"), - {"id": identity_link_id}, - ) - assert isinstance(profile_status, str) - assert isinstance(link_status, str) - return profile_status, link_status - finally: - await engine.dispose() - - -async def _restore_contributor_after_lifecycle_race( - database_url: str, - actor_profile_id: str, - identity_link_id: str, -) -> None: - """Return a terminal test actor to active state under explicit test custody.""" - engine = create_async_engine(database_url) - try: - async with engine.connect() as connection: - reset = await connection.begin() - try: - await connection.execute( - text("alter table actor_profiles disable trigger actor_profile_history_guard") - ) - await connection.execute( - text( - "alter table actor_identity_links disable trigger " - "actor_identity_link_history_guard" - ) - ) - await connection.execute( - text( - "update actor_profiles set status = 'active', " - "suspended_by = null, suspended_at = null, " - "suspension_reason = null, reactivated_by = null, " - "reactivated_at = null, reactivation_reason = null, " - "deactivated_by = null, deactivated_at = null, " - "deactivation_reason = null where id = :id" - ), - {"id": actor_profile_id}, - ) - await connection.execute( - text( - "update actor_identity_links set status = 'active', " - "revoked_by = null, revoked_at = null, revoked_reason = null, " - "reactivated_by = null, reactivated_at = null, " - "reactivation_reason = null where id = :id" - ), - {"id": identity_link_id}, - ) - await reset.commit() - except BaseException: - await reset.rollback() - raise - finally: - enable = await connection.begin() - try: - await connection.execute( - text( - "alter table actor_identity_links enable trigger " - "actor_identity_link_history_guard" - ) - ) - await connection.execute( - text( - "alter table actor_profiles enable trigger actor_profile_history_guard" - ) - ) - await enable.commit() - except BaseException: - await enable.rollback() - raise - finally: - await engine.dispose() - - -@pytest.fixture -async def contributor_lifecycle_race_cleanup( - task_database_env: str, -) -> AsyncIterator[list[tuple[str, str]]]: - """Restore lifecycle race actors before the migration fixture downgrades.""" - actors: list[tuple[str, str]] = [] - yield actors - for actor_profile_id, identity_link_id in actors: - await _restore_contributor_after_lifecycle_race( - task_database_env, - actor_profile_id, - identity_link_id, - ) - - -_CONTRIBUTOR_LOCK_RACE_CASES = [ - (operation, transition, ordering) - for operation in ("claim", "submission") - for transition in ("suspend", "deactivate", "revoke_link") - for ordering in ("lifecycle_first", "task_write_first") -] - - -@pytest.mark.parametrize( - ("operation", "transition", "ordering"), - _CONTRIBUTOR_LOCK_RACE_CASES, - ids=["-".join(case) for case in _CONTRIBUTOR_LOCK_RACE_CASES], -) -async def test_contributor_task_writes_serialize_with_lifecycle_changes( - task_client: AsyncClient, - task_database_env: str, - contributor_lifecycle_race_cleanup: list[tuple[str, str]], - monkeypatch: pytest.MonkeyPatch, - operation: str, - transition: str, - ordering: str, -) -> None: - """Prove all twelve contributor-write and lifecycle lock order outcomes.""" - project = await create_active_project(task_client) - subject = f"race-{operation}-{transition}-{ordering}" - contributor_id = actor_id(subject) - checker_calls: list[str] = [] - enqueue_calls: list[str] = [] - if operation == "claim": - task = await create_ready_task(task_client, project["id"]) - await seed_worker_profile(subject) - else: - task = await create_started_task( - task_client, - project["id"], - monkeypatch, - subject, - ) - original_pre_submit_check = CheckerService.pre_submit_check - - async def count_pre_submit_check(self, *args, **kwargs): - checker_calls.append(task["id"]) - return await original_pre_submit_check(self, *args, **kwargs) - - def count_pre_review_enqueue( - *, - checker_run_id: str, - requester_provenance: dict, - ) -> str: - enqueue_calls.append(checker_run_id) - return hold_pre_review_enqueue( - checker_run_id=checker_run_id, - requester_provenance=requester_provenance, - ) - - monkeypatch.setattr( - CheckerService, - "pre_submit_check", - count_pre_submit_check, - ) - monkeypatch.setattr( - "app.modules.tasks.service.enqueue_pre_review_gate", - count_pre_review_enqueue, - ) - - async with db_session.get_session_factory()() as session: - identity_link_id = await session.scalar( - select(ActorIdentityLink.id).where(ActorIdentityLink.actor_profile_id == contributor_id) - ) - assert identity_link_id is not None - contributor_lifecycle_race_cleanup.append((contributor_id, identity_link_id)) - - actor = ActorContext( - actor_id=contributor_id, - external_subject=subject, - external_issuer="flow-test", - roles=("worker",), - claim_snapshot={"roles": ["worker"]}, - auth_source="dev_mock", - is_dev_auth=True, - ) - task_id = task["id"] - before = await _read_task_contributor_race_snapshot(task_database_env, task_id) - task_application_name = f"ws-race-{operation}-{transition}-{ordering}-task" - lifecycle_application_name = f"ws-race-{operation}-{transition}-{ordering}-lifecycle" - - if ordering == "lifecycle_first": - lifecycle_entered = asyncio.Event() - lifecycle_locked = asyncio.Event() - release_lifecycle = asyncio.Event() - lifecycle_call = asyncio.create_task( - _run_contributor_lifecycle_write( - task_database_env, - actor_profile_id=contributor_id, - identity_link_id=identity_link_id, - transition=transition, - task_id=task_id, - application_name=lifecycle_application_name, - entered=lifecycle_entered, - locked=lifecycle_locked, - release=release_lifecycle, - ), - name=lifecycle_application_name, - ) - await lifecycle_entered.wait() - await lifecycle_locked.wait() - task_entered = asyncio.Event() - task_call = asyncio.create_task( - _run_task_contributor_write( - task_database_env, - actor=actor, - task_id=task_id, - operation=operation, - application_name=task_application_name, - entered=task_entered, - ), - name=task_application_name, - ) - await task_entered.wait() - lock_error: AssertionError | None = None - try: - await _wait_for_task_database_lock( - task_database_env, - task_application_name, - ) - except AssertionError as exc: - lock_error = exc - finally: - release_lifecycle.set() - lifecycle_result, task_result = await asyncio.gather( - lifecycle_call, - task_call, - return_exceptions=True, - ) - if lock_error is not None: - raise lock_error - assert lifecycle_result is None - assert isinstance(task_result, ActiveContributorRequired) - assert task_result.code == "active_contributor_required" - assert ( - await _read_task_contributor_race_snapshot( - task_database_env, - task_id, - ) - == before - ) - if operation == "submission": - assert checker_calls == [] - assert enqueue_calls == [] - else: - task_locked = asyncio.Event() - release_task = asyncio.Event() - original_guard = ActorService.require_active_human_write_actor - - async def hold_task_after_contributor_lock( - service: ActorService, - current_actor: ActorContext, - ) -> None: - await original_guard(service, current_actor) - if current_actor.actor_id == contributor_id: - task_locked.set() - await release_task.wait() - - monkeypatch.setattr( - ActorService, - "require_active_human_write_actor", - hold_task_after_contributor_lock, - ) - task_entered = asyncio.Event() - task_call = asyncio.create_task( - _run_task_contributor_write( - task_database_env, - actor=actor, - task_id=task_id, - operation=operation, - application_name=task_application_name, - entered=task_entered, - ), - name=task_application_name, - ) - await task_entered.wait() - await task_locked.wait() - lifecycle_entered = asyncio.Event() - lifecycle_call = asyncio.create_task( - _run_contributor_lifecycle_write( - task_database_env, - actor_profile_id=contributor_id, - identity_link_id=identity_link_id, - transition=transition, - task_id=task_id, - application_name=lifecycle_application_name, - entered=lifecycle_entered, - observe_task_after_lock=True, - ), - name=lifecycle_application_name, - ) - await lifecycle_entered.wait() - lock_error = None - try: - await _wait_for_task_database_lock( - task_database_env, - lifecycle_application_name, - ) - except AssertionError as exc: - lock_error = exc - finally: - release_task.set() - task_result, observed_after_task_commit = await asyncio.gather( - task_call, - lifecycle_call, - return_exceptions=True, - ) - if lock_error is not None: - raise lock_error - if isinstance(task_result, BaseException): - raise task_result - if isinstance(observed_after_task_commit, BaseException): - raise observed_after_task_commit - assert isinstance(observed_after_task_commit, dict) - if operation == "claim": - assert task_result.assignment.contributor_id == contributor_id - assert observed_after_task_commit["task"] == ( - "claimed", - contributor_id, - ) - assignments = observed_after_task_commit["assignments"] - assert isinstance(assignments, list) - assert len(assignments) == 1 - assert assignments[0][1] == contributor_id - else: - assert task_result.contributor_id == contributor_id - assert checker_calls == [task_id] - assert len(enqueue_calls) == 1 - assert observed_after_task_commit["task"] == ( - "submitted", - contributor_id, - ) - submissions = observed_after_task_commit["submissions"] - assert isinstance(submissions, list) - assert len(submissions) == 1 - assert submissions[0][1] == contributor_id - - profile_status, link_status = await _read_contributor_lifecycle_state( - task_database_env, - contributor_id, - identity_link_id, - ) - if transition == "suspend": - assert (profile_status, link_status) == ("suspended", "active") - elif transition == "deactivate": - assert (profile_status, link_status) == ("deactivated", "active") - else: - assert (profile_status, link_status) == ("active", "revoked") -async def seed_actor_profile( - subject: str, - *, - profile_type: str, - status: str = "active", - skill_tags: list[str] | None = None, -) -> str: - """Seed one actor identity and global actor profile for authorization tests.""" - seeded_actor_id = actor_id(subject) - async with db_session.get_session_factory()() as session: - session.add_all( - [ - ActorProfile( - id=seeded_actor_id, - actor_kind="human", - status="active", - provisioning_method="automatic_first_access", - created_by=seeded_actor_id, - ), - ActorIdentityLink( - id=str(uuid4()), - actor_profile_id=seeded_actor_id, - issuer="flow-test", - subject=subject, - subject_kind="human", - status="active", - linked_by=seeded_actor_id, - last_verified_at=datetime.now(UTC), - ), - LegacyActorIdentity( - actor_id=seeded_actor_id, - external_subject=subject, - external_issuer="flow-test", - display_name=subject.replace("-", " ").title(), - email=f"{subject}@example.test", - last_seen_roles=[profile_type], - last_claim_snapshot={"seeded_for_task_test": True}, - auth_source="dev_mock", - is_dev_auth=True, - ), - LegacyWorkflowEligibility( - id=str(uuid4()), - actor_id=seeded_actor_id, - profile_type=profile_type, - status=status, - skill_tags=skill_tags or [], - scope_type="global", - scope_id="global", - profile_metadata={"seeded_for_task_test": True}, - ), - ] - ) - await session.commit() - return seeded_actor_id def test_task_models_are_registered_for_alembic_metadata() -> None: @@ -2309,22 +1593,13 @@ def test_task_assignment_partial_unique_index_metadata_compiles() -> None: assert "status = 'active'" in postgres_compiled -def test_submission_create_openapi_documents_domain_error() -> None: - schema = create_app().openapi() - responses = schema["paths"]["/api/v1/tasks/{task_id}/submissions"]["post"]["responses"] - response_422 = responses["422"]["content"]["application/json"]["schema"] - - assert {"$ref": "#/components/schemas/HTTPValidationError"} in response_422["oneOf"] - domain_schema = next(option for option in response_422["oneOf"] if "properties" in option) - assert domain_schema["properties"]["code"]["enum"] == ["pre_submission_checker_failed"] - assert "details" in domain_schema["properties"] - assert set(domain_schema["required"]) == {"code", "details", "error"} - assert domain_schema["additionalProperties"] is False - - -def test_task_context_openapi_documents_locked_context_domain_error() -> None: +@pytest.mark.parametrize("path", [ + "/api/v1/tasks/{task_id}/work-context", + "/api/v1/projects/{project_id}/tasks/{task_id}/work-context", +]) +def test_task_context_openapi_documents_locked_context_domain_error(path: str) -> None: schema = create_app().openapi() - responses = schema["paths"]["/api/v1/tasks/{task_id}/work-context"]["get"]["responses"] + responses = schema["paths"][path]["get"]["responses"] response_422 = responses["422"]["content"]["application/json"]["schema"] assert {"$ref": "#/components/schemas/HTTPValidationError"} in response_422["oneOf"] @@ -2433,7 +1708,6 @@ async def fail_with_service_error(*_args, **_kwargs): cases = [ ("create_task", "POST", "/api/v1/projects/project-id/tasks", complete_task_payload()), ("get_task", "GET", "/api/v1/tasks/task-id", None), - ("get_task_work_context", "GET", "/api/v1/tasks/task-id/work-context", None), ( "get_task_submission_requirements", "GET", @@ -2443,14 +1717,6 @@ async def fail_with_service_error(*_args, **_kwargs): ("get_task_locked_context", "GET", "/api/v1/tasks/task-id/locked-context", None), ("move_to_screening", "POST", "/api/v1/tasks/task-id/screen", None), ("release_to_ready", "POST", "/api/v1/tasks/task-id/release", None), - ("claim_task", "POST", "/api/v1/tasks/task-id/claim", None), - ("start_task", "POST", "/api/v1/tasks/task-id/start", None), - ( - "create_submission", - "POST", - "/api/v1/tasks/task-id/submissions", - complete_submission_payload(), - ), ("list_task_submissions", "GET", "/api/v1/tasks/task-id/submissions", None), ("get_submission", "GET", "/api/v1/submissions/submission-id", None), ("finalize_submission", "POST", "/api/v1/submissions/submission-id/finalize", None), @@ -2481,100 +1747,24 @@ async def fail_with_permission_error(*_args, **_kwargs): assert denied.json()["detail"] == "bounded permission failure" assert denied.json()["error"]["code"] == "permission_not_granted" - async def fail_inactive_contributor(*_args, **_kwargs): - raise ActiveContributorRequired(ActiveContributorRequired.message) - monkeypatch.setattr(TaskService, "claim_task", fail_inactive_contributor) - inactive = await task_client.post( - "/api/v1/tasks/task-id/claim", - headers=auth_headers(), - json={"reason": "claim"}, - ) - assert inactive.status_code == 403 - assert inactive.json()["detail"] == "Active contributor identity required" - assert inactive.json()["error"]["code"] == "active_contributor_required" - assert inactive.json()["error"]["retryable"] is False - async def fail_contributor_lookup(*_args, **_kwargs): - raise ContributorIdentityUnavailable(ContributorIdentityUnavailable.message) - monkeypatch.setattr(TaskService, "create_submission", fail_contributor_lookup) - unavailable = await task_client.post( - "/api/v1/tasks/task-id/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) +async def test_task_can_be_created_in_draft(task_client: AsyncClient) -> None: + project = await create_active_project(task_client) + task = await create_draft_task(task_client, project["id"]) - assert unavailable.status_code == 503 - assert unavailable.json()["error"]["message"] == ( - "Contributor identity verification unavailable" - ) - assert unavailable.json()["error"]["code"] == "contributor_identity_unavailable" - assert unavailable.json()["error"]["retryable"] is True + assert task["status"] == "draft" + assert "locked_guide_version" not in task + assert task["skill_tags"] == ["stem", "proofs"] + assert task["source_ref"] == "local-ticket-1" + assert "required_files" not in task + assert "required_evidence" not in task -async def test_legacy_eligibility_service_updates_existing_submitter_row( - task_database_env: str, -) -> None: - async with db_session.get_session_factory()() as session: - service = ActorService(session) - worker_actor = ActorContext( - actor_id=actor_id("worker-upsert"), - external_subject="worker-upsert", - external_issuer="flow-test", - display_name="Worker Upsert", - email="worker-upsert@example.test", - roles=("worker",), - claim_snapshot={"roles": ("worker",)}, - auth_source="dev_mock", - is_dev_auth=True, - ) - first_worker = await service.activate_legacy_workflow_eligibility( - worker_actor, - LegacyWorkflowEligibilityActivationRequest(skill_tags=["stem"]), - ) - updated_worker = await service.activate_legacy_workflow_eligibility( - worker_actor.model_copy( - update={"display_name": "Worker Updated", "email": "worker-updated@example.test"} - ), - LegacyWorkflowEligibilityActivationRequest(skill_tags=["stem", "analysis"]), - ) - - async with db_session.get_session_factory()() as session: - worker_rows = ( - ( - await session.execute( - select(LegacyWorkflowEligibility).where( - LegacyWorkflowEligibility.actor_id == worker_actor.actor_id, - LegacyWorkflowEligibility.profile_type == "worker", - ) - ) - ) - .scalars() - .all() - ) - - assert updated_worker.id == first_worker.id - assert updated_worker.skill_tags == ["stem", "analysis"] - assert len(worker_rows) == 1 - assert worker_rows[0].id == first_worker.id - - -async def test_task_can_be_created_in_draft(task_client: AsyncClient) -> None: - project = await create_active_project(task_client) - task = await create_draft_task(task_client, project["id"]) - - assert task["status"] == "draft" - assert "locked_guide_version" not in task - assert task["skill_tags"] == ["stem", "proofs"] - assert task["source_ref"] == "local-ticket-1" - assert "required_files" not in task - assert "required_evidence" not in task - - -async def test_task_create_rejects_task_owned_artifact_requirement_fields( - task_client: AsyncClient, +async def test_task_create_rejects_task_owned_artifact_requirement_fields( + task_client: AsyncClient, ) -> None: project = await create_active_project(task_client) payload = complete_task_payload() @@ -2863,7 +2053,7 @@ async def test_worker_task_response_redacts_locked_policy_hashes( assert operator_response.status_code == 200, operator_response.text - await seed_worker_profile("worker-one") + await seed_task_test_actor("worker-one") set_dev_actor(monkeypatch, roles="worker", subject="worker-one") response = await task_client.get( @@ -2918,7 +2108,9 @@ async def test_task_context_apis_return_worker_requirements_and_operator_provena assert work_body["guide"]["change_summary"] == "Initial v1" assert "content_markdown" not in work_body["guide"] assert work_body["payment_policy"]["base_amount"] == "25.00" - assert work_body["lifecycle"]["can_submit"] is True + assert work_body["lifecycle"]["can_submit"] is False + assert work_body["lifecycle"]["can_run_pre_submit_check"] is False + assert work_body["lifecycle"]["next_actions"] == [] worker_context_json = json.dumps(work_body, sort_keys=True) for internal_field in ( "locked_guide_source_snapshot_hash", @@ -3015,8 +2207,9 @@ async def test_ready_worker_work_context_omits_private_task_source_fields( payload["import_batch_id"] = "ready-private-import" payload["external_task_id"] = "ready-private-external" ready_task = await create_ready_task(task_client, project["id"], payload) - await seed_worker_profile("worker-one") - set_dev_actor(monkeypatch, roles="worker", subject="worker-one") + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "worker-one", + ) response = await task_client.get( f"/api/v1/tasks/{ready_task['id']}/work-context", @@ -3112,6 +2305,11 @@ async def test_task_context_apis_fail_closed_on_stale_locked_context_rows( ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) + # Use the manager's actual grant-backed route so AUTH cannot mask a + # locked-policy validation defect with an earlier contributor denial. + context_url = f"/api/v1/projects/{project['id']}/tasks/{ready_task['id']}/work-context" + before = await task_client.get(context_url, headers=auth_headers()) + assert before.status_code == 200, before.text async with db_session.get_session_factory()() as session: persisted_task = await session.get(WorkstreamTask, ready_task["id"]) @@ -3154,12 +2352,9 @@ async def test_task_context_apis_fail_closed_on_stale_locked_context_rows( } await session.commit() - response = await task_client.get( - f"/api/v1/tasks/{ready_task['id']}/work-context", - headers=auth_headers(), - ) + response = await task_client.get(context_url, headers=auth_headers()) - assert response.status_code == 422 + assert response.status_code == 422, response.text assert response.json()["code"] == "task_locked_context_invalid" @@ -3336,101 +2531,6 @@ async def test_tasks_under_same_active_guide_share_project_pre_submit_checker( ) -async def test_submission_runtime_uses_locked_project_policy_not_task_required_fields( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - - project_policy_task = await create_started_task( - task_client, - project["id"], - monkeypatch, - "worker-one", - complete_task_payload(), - ) - project_policy_response = await task_client.post( - f"/api/v1/tasks/{project_policy_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - assert project_policy_response.status_code == 201, project_policy_response.text - - non_contract_artifact_task = await create_started_task( - task_client, - project["id"], - monkeypatch, - "worker-two", - complete_task_payload(), - ) - non_contract_artifact_payload = complete_submission_payload("sha256:non-contract-package") - non_contract_artifact_payload["artifact_hash_manifest"] = [ - { - "artifact": "non-contract-only.md", - "hash": "sha256:non-contract-only-v1", - "size_bytes": 128, - "notes": "does not match the locked project policy", - } - ] - non_contract_artifact_response = await task_client.post( - f"/api/v1/tasks/{non_contract_artifact_task['id']}/submissions", - headers=auth_headers(), - json=non_contract_artifact_payload, - ) - - assert non_contract_artifact_response.status_code == 422, non_contract_artifact_response.text - detail = non_contract_artifact_response.json() - assert set(detail) == {"code", "details", "error"} - assert detail["code"] == "pre_submission_checker_failed" - assert detail["error"]["code"] == "pre_submission_checker_failed" - assert detail["error"]["details"] == detail["details"] - required_files = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_required_files" - ) - assert required_files["status"] == "failed" - - non_contract_evidence_task = await create_started_task( - task_client, - project["id"], - monkeypatch, - "worker-three", - complete_task_payload(), - ) - non_contract_evidence_payload = complete_submission_payload( - "sha256:non-contract-evidence-package" - ) - non_contract_evidence_payload["artifact_hash_manifest"][0]["hash"] = ( - "sha256:answer-non-contract-evidence" - ) - non_contract_evidence_payload["evidence_items"] = [ - { - "type": "note", - "label": "non-contract evidence", - "uri": "local://evidence/non-contract-evidence.txt", - "hash": "sha256:non-contract-evidence-v1", - "size_bytes": 128, - "metadata": {"policy_key": "non_contract_evidence"}, - } - ] - non_contract_evidence_response = await task_client.post( - f"/api/v1/tasks/{non_contract_evidence_task['id']}/submissions", - headers=auth_headers(), - json=non_contract_evidence_payload, - ) - - assert non_contract_evidence_response.status_code == 422, non_contract_evidence_response.text - detail = non_contract_evidence_response.json() - assert detail["code"] == "pre_submission_checker_failed" - required_evidence = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_evidence_present" - ) - assert required_evidence["status"] == "failed" - - async def test_release_requires_decision_reason(task_client: AsyncClient) -> None: project = await create_active_project(task_client) task = await create_draft_task(task_client, project["id"]) @@ -3457,8 +2557,10 @@ async def test_full_task_claim_start_flow_writes_audit_events( ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - worker_actor_id = await seed_worker_profile("worker-one") - set_dev_actor(monkeypatch, roles="worker", subject="worker-one") + grant = await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "worker-one", + ) + worker_actor_id = grant["actor_profile_id"] claim = await task_client.post( f"/api/v1/tasks/{ready_task['id']}/claim", @@ -3477,6 +2579,7 @@ async def test_full_task_claim_start_flow_writes_audit_events( assert start.status_code == 200, start.text assert start.json()["status"] == "in_progress" + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") audit = await task_client.get( f"/api/v1/tasks/{ready_task['id']}/audit-events", headers=auth_headers(), @@ -3518,231 +2621,136 @@ async def test_full_task_claim_start_flow_writes_audit_events( assert event["event_payload"]["locked_payment_policy_version"] == "v1" claim_event = next(event for event in events if event["to_status"] == "claimed") assert claim_event["actor_id"] == worker_actor_id - assert claim_event["external_subject"] == "worker-one" - assert claim_event["external_issuer"] == "flow-test" - assert claim_event["actor_roles"] == ["worker"] + assert claim_event["actor_roles"] == [] assert claim_event["claim_snapshot"] == {} - assert claim_event["auth_source"] == "dev_mock" - assert claim_event["is_dev_auth"] is True - assert claim_event["event_payload"]["assignment_id"] == claim.json()["assignment"]["id"] + assert claim_event["is_dev_auth"] is False + references = claim_event["event_payload"]["references"] + assert references["assignment_id"] == claim.json()["assignment"]["id"] + assert references["task_id"] == ready_task["id"] + assert references["project_id"] == project["id"] async with db_session.get_session_factory()() as session: persisted_event = await session.get(AuditEvent, claim_event["id"]) + decision = await session.get(AuditEvent, references["authorization_decision_id"]) + assert decision.action_id == "task.claim" + assert decision.actor_id == worker_actor_id + assert decision.after_facts["allowed"] is True assert persisted_event is not None - assert persisted_event.claim_snapshot["roles"] == ["worker"] + assert persisted_event.claim_snapshot == {} -async def test_worker_without_profile_cannot_claim_ready_task( +@pytest.mark.parametrize("authority_state", ["absent", "revoked"]) +async def test_submitter_without_current_project_grant_cannot_claim( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, + authority_state: str, ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - set_dev_actor(monkeypatch, roles="worker", subject="worker-without-profile") - + subject = f"claim-grant-{authority_state}" + if authority_state == "revoked": + grant = await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], subject, + ) + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + revoked = await task_client.post( + f"/api/v1/projects/{project['id']}/role-grants/{grant['grant_id']}/revoke", + headers=auth_headers(), json={"reason": "Withdraw project authority"}, + ) + assert revoked.status_code == 200, revoked.text + # Even a token worker role cannot supply absent or revoked project authority. + set_dev_actor(monkeypatch, roles="worker", subject=subject) response = await task_client.post( f"/api/v1/tasks/{ready_task['id']}/claim", - headers=auth_headers(), - json={"reason": "claim"}, + headers=auth_headers(), json={"reason": "claim"}, ) - - assert response.status_code == 403 - assert "active legacy submitter eligibility" in response.json()["detail"] + assert response.status_code == 403, response.text context = await task_client.get( - f"/api/v1/tasks/{ready_task['id']}/work-context", - headers=auth_headers(), - ) - assert context.status_code == 200, context.text - assert context.json()["lifecycle"]["next_actions"] == [] - - -async def test_disabled_worker_profile_cannot_claim_ready_task( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - ready_task = await create_ready_task(task_client, project["id"]) - await seed_actor_profile("disabled-worker", profile_type="worker", status="disabled") - set_dev_actor(monkeypatch, roles="worker", subject="disabled-worker") - - response = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/claim", - headers=auth_headers(), - json={"reason": "claim with disabled profile"}, + f"/api/v1/tasks/{ready_task['id']}/work-context", headers=auth_headers(), ) - - assert response.status_code == 403 - assert "active legacy submitter eligibility" in response.json()["detail"] + assert context.status_code == 403, context.text async with db_session.get_session_factory()() as session: - assignment = await session.scalar( - select(TaskAssignment).where(TaskAssignment.task_id == ready_task["id"]) - ) + assert await session.scalar(select(TaskAssignment).where( + TaskAssignment.task_id == ready_task["id"], + )) is None task = await session.get(WorkstreamTask, ready_task["id"]) - assert assignment is None - assert task is not None - assert task.status == "ready" + assert task.status == "ready" and task.assigned_to is None -async def test_disabled_legacy_eligibility_after_claim_blocks_assigned_submitter_start( +@pytest.mark.parametrize("state_before_revocation", ["claimed", "in_progress"]) +async def test_revocation_blocks_contributor_commands_without_rewriting_assignment( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, + state_before_revocation: str, ) -> None: project = await create_active_project(task_client) - ready_task = await create_ready_task(task_client, project["id"]) - worker_actor_id = await seed_worker_profile("eligibility-disabled-after-claim") - set_dev_actor( - monkeypatch, - roles="worker", - subject="eligibility-disabled-after-claim", + task = await create_ready_task(task_client, project["id"]) + subject = "revoked-assignee" + grant = await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], subject, ) - claim = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/claim", - headers=auth_headers(), - json={"reason": "claim while eligible"}, + claimed = await task_client.post( + f"/api/v1/tasks/{task['id']}/claim", headers=auth_headers(), ) - assert claim.status_code == 200, claim.text - - async with db_session.get_session_factory()() as session: - eligibility = await session.scalar( - select(LegacyWorkflowEligibility).where( - LegacyWorkflowEligibility.actor_id == worker_actor_id, - LegacyWorkflowEligibility.profile_type == "worker", - ) + assert claimed.status_code == 200, claimed.text + if state_before_revocation == "in_progress": + started = await task_client.post( + f"/api/v1/tasks/{task['id']}/start", headers=auth_headers(), ) - assert eligibility is not None - eligibility.status = "disabled" - await session.commit() - - start = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/start", - headers=auth_headers(), - json={"reason": "start after eligibility disabled"}, - ) - assert start.status_code == 403 - assert "active legacy submitter eligibility" in start.json()["detail"] - read = await task_client.get(f"/api/v1/tasks/{ready_task['id']}", headers=auth_headers()) - assert read.status_code == 200 - assert read.json()["status"] == "claimed" - context = await task_client.get( - f"/api/v1/tasks/{ready_task['id']}/work-context", - headers=auth_headers(), - ) - assert context.status_code == 200, context.text - assert context.json()["lifecycle"]["next_actions"] == [] - - -async def test_disabled_eligibility_suppresses_submit_lifecycle_affordances( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - subject = "eligibility-disabled-after-start" - started_task = await create_started_task( - task_client, - project["id"], - monkeypatch, - subject=subject, + assert started.status_code == 200, started.text + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + revoked = await task_client.post( + f"/api/v1/projects/{project['id']}/role-grants/{grant['grant_id']}/revoke", + headers=auth_headers(), json={"reason": "Withdraw project authority"}, ) - async with db_session.get_session_factory()() as session: - eligibility = await session.scalar( - select(LegacyWorkflowEligibility).where( - LegacyWorkflowEligibility.actor_id == actor_id(subject), - LegacyWorkflowEligibility.profile_type == "worker", - ) + assert revoked.status_code == 200, revoked.text + set_dev_actor(monkeypatch, roles="viewer", subject=subject) + for method, action in (("post", "start"), ("get", "work-context")): + denied = await getattr(task_client, method)( + f"/api/v1/tasks/{task['id']}/{action}", headers=auth_headers(), ) - assert eligibility is not None - eligibility.status = "disabled" - await session.commit() - - context = await task_client.get( - f"/api/v1/tasks/{started_task['id']}/work-context", - headers=auth_headers(), - ) - - assert context.status_code == 200, context.text - lifecycle = context.json()["lifecycle"] - assert lifecycle["can_run_pre_submit_check"] is False - assert lifecycle["can_submit"] is False - assert lifecycle["next_actions"] == [] - - async with db_session.get_session_factory()() as session: - before = { - "submissions": await session.scalar( - select(func.count()) - .select_from(Submission) - .where(Submission.task_id == started_task["id"]) - ), - "checker_runs": await session.scalar( - select(func.count()).select_from(db_models.CheckerRun) - ), - "audit_events": await session.scalar(select(func.count()).select_from(AuditEvent)), - } - submission = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - assert submission.status_code == 403 - assert "active legacy submitter eligibility" in submission.json()["detail"] + assert denied.status_code == 403, denied.text async with db_session.get_session_factory()() as session: - after = { - "submissions": await session.scalar( - select(func.count()) - .select_from(Submission) - .where(Submission.task_id == started_task["id"]) - ), - "checker_runs": await session.scalar( - select(func.count()).select_from(db_models.CheckerRun) - ), - "audit_events": await session.scalar(select(func.count()).select_from(AuditEvent)), - } - assert after == before - - -async def test_active_worker_profile_without_worker_token_cannot_claim( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, + stored = await session.get(WorkstreamTask, task["id"]) + assert stored.status == state_before_revocation + assert stored.assigned_to == grant["actor_profile_id"] + assignment = await session.get(TaskAssignment, claimed.json()["assignment"]["id"]) + assert assignment.status == "active" + assert assignment.contributor_id == grant["actor_profile_id"] + assert await session.scalar(select(Submission).where( + Submission.task_id == task["id"], + )) is None + + +async def test_project_grant_allows_claim_without_worker_token_role( + task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) - ready_task = await create_ready_task(task_client, project["id"]) - set_dev_actor(monkeypatch, roles="worker", subject="project-manager-subject") - activation = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": []}, + task = await create_ready_task(task_client, project["id"]) + grant = await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "role-free-submitter", ) - assert activation.status_code == 200, activation.text - set_dev_actor( - monkeypatch, - roles="project_manager", - subject="project-manager-subject", - ) - - response = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/claim", - headers=auth_headers(), - json={"reason": "claim without worker token role"}, + claimed = await task_client.post( + f"/api/v1/tasks/{task['id']}/claim", headers=auth_headers(), ) - - assert response.status_code == 403 - assert "actor lacks required role" in response.json()["detail"] + assert claimed.status_code == 200, claimed.text + assert claimed.json()["assignment"]["contributor_id"] == grant["actor_profile_id"] context = await task_client.get( - f"/api/v1/tasks/{ready_task['id']}/work-context", - headers=auth_headers(), + f"/api/v1/tasks/{task['id']}/work-context", headers=auth_headers(), ) assert context.status_code == 200, context.text - assert context.json()["lifecycle"]["next_actions"] == [] + assert context.json()["lifecycle"]["next_actions"] == ["start"] @pytest.mark.parametrize("profile_type", ["admin", "project_manager"]) -async def test_active_operator_profile_without_matching_token_cannot_create_task( +async def test_stored_role_metadata_does_not_authorize_task_creation( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, profile_type: str, ) -> None: project = await create_active_project(task_client) subject = f"{profile_type}-profile-only" - await seed_actor_profile(subject, profile_type=profile_type) + await seed_task_test_actor(subject, stored_role=profile_type) set_dev_actor(monkeypatch, roles="worker", subject=subject) response = await task_client.post( @@ -3755,131 +2763,10 @@ async def test_active_operator_profile_without_matching_token_cannot_create_task assert "actor lacks required role" in response.json()["detail"] -async def test_worker_can_create_profile_before_claiming_task( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - ready_task = await create_ready_task(task_client, project["id"]) - set_dev_actor(monkeypatch, roles="worker", subject="worker-self-profile") - - profile = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": [" Terminal_Benchmark ", "GO", "go"]}, - ) - assert profile.status_code == 200, profile.text - profile_body = profile.json() - assert profile_body["actor_id"] == actor_id("worker-self-profile") - assert profile_body["external_subject"] == "worker-self-profile" - assert profile_body["skill_tags"] == ["terminal_benchmark", "go"] - assert profile_body["status"] == "active" - - refreshed_profile = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": ["stem"]}, - ) - assert refreshed_profile.status_code == 200, refreshed_profile.text - assert refreshed_profile.json()["id"] == profile_body["id"] - assert refreshed_profile.json()["skill_tags"] == ["stem"] - - claim = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/claim", - headers=auth_headers(), - json={"reason": "claim after self profile"}, - ) - - assert claim.status_code == 200, claim.text - assert claim.json()["task"]["status"] == "claimed" - assert claim.json()["assignment"]["contributor_id"] == actor_id("worker-self-profile") - - -async def test_worker_profile_response_excludes_identity_display_fields( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - set_dev_actor( - monkeypatch, - roles="worker", - subject="worker-null-identity", - email=None, - display_name=None, - ) - - response = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": []}, - ) - - assert response.status_code == 200, response.text - body = response.json() - assert body["actor_id"] == actor_id("worker-null-identity") - assert body["external_subject"] == "worker-null-identity" - assert body["external_issuer"] == "flow-test" - assert "display_name" not in body - assert "email" not in body - assert body["skill_tags"] == [] - assert body["status"] == "active" -async def test_worker_profile_request_is_fail_closed_and_validated( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - subject = "worker-profile-validation" - set_dev_actor(monkeypatch, roles="worker", subject=subject) - spoofed_fields = { - "actor_id": actor_id("malicious"), - "external_subject": "spoofed-subject", - "external_issuer": "spoofed-issuer", - "roles": ["admin"], - "email": "spoofed@example.test", - "display_name": "Spoofed Name", - } - for field_name, field_value in spoofed_fields.items(): - unknown_field = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={ - "skill_tags": ["stem"], - field_name: field_value, - }, - ) - assert unknown_field.status_code == 422 - assert field_name in unknown_field.text - - identity, profiles = await fetch_legacy_actor_rows(subject) - malicious_identity, malicious_profiles = await fetch_legacy_actor_rows("malicious") - - assert malicious_identity is None - assert malicious_profiles == [] - assert identity is not None - assert identity.actor_id == actor_id(subject) - assert identity.external_subject == subject - assert identity.external_issuer == "flow-test" - assert identity.email is None - assert identity.display_name is None - assert identity.last_seen_roles == ["worker"] - assert profiles == [] - - blank_tag = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": [" "]}, - ) - long_tag = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": ["x" * 65]}, - ) - assert blank_tag.status_code == 422 - assert "invalid skill tag" in blank_tag.text - assert long_tag.status_code == 422 - assert "invalid skill tag" in long_tag.text async def test_registered_claim_route_rejects_identity_spoof_fields( @@ -3888,13 +2775,9 @@ async def test_registered_claim_route_rejects_identity_spoof_fields( ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - set_dev_actor(monkeypatch, roles="worker", subject="worker-claim-overpost") - profile = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": ["stem"]}, + grant = await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "worker-claim-overpost", ) - assert profile.status_code == 200, profile.text spoofed_fields = { "actor_id": actor_id("malicious"), @@ -3913,37 +2796,19 @@ async def test_registered_claim_route_rejects_identity_spoof_fields( assert response.status_code == 422 assert field_name in response.text - identity, profiles = await fetch_legacy_actor_rows("worker-claim-overpost") - malicious_identity, malicious_profiles = await fetch_legacy_actor_rows("malicious") - - assert malicious_identity is None - assert malicious_profiles == [] - assert identity is not None - assert identity.actor_id == actor_id("worker-claim-overpost") - assert identity.external_subject == "worker-claim-overpost" - assert identity.external_issuer == "flow-test" - assert identity.email is None - assert identity.display_name is None - assert identity.last_seen_roles == ["worker"] - assert [(profile.profile_type, profile.status, profile.skill_tags) for profile in profiles] == [ - ("worker", "active", ["stem"]) - ] - - -async def test_worker_profile_requires_worker_role( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + async with db_session.get_session_factory()() as session: + profile = await session.get(ActorProfile, grant["actor_profile_id"]) + assert profile.actor_kind == "human" and profile.status == "active" + assert profile.display_name != "Spoofed Name" + assert profile.contact_email != "spoofed@example.test" + assert await session.get(ActorProfile, actor_id("malicious")) is None + assert await session.scalar(select(TaskAssignment).where( + TaskAssignment.task_id == ready_task["id"], + )) is None + task = await session.get(WorkstreamTask, ready_task["id"]) + assert task.status == "ready" and task.assigned_to is None - response = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": ["stem"]}, - ) - assert response.status_code == 403 - assert "actor lacks required role" in response.json()["detail"] async def test_second_claim_is_rejected( @@ -3951,8 +2816,9 @@ async def test_second_claim_is_rejected( ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - await seed_worker_profile("worker-one") - set_dev_actor(monkeypatch, roles="worker", subject="worker-one") + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "worker-one", + ) first_claim = await task_client.post( f"/api/v1/tasks/{ready_task['id']}/claim", headers=auth_headers(), @@ -3960,15 +2826,16 @@ async def test_second_claim_is_rejected( ) assert first_claim.status_code == 200, first_claim.text - await seed_worker_profile("worker-two") - set_dev_actor(monkeypatch, roles="worker", subject="worker-two") + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "worker-two", + ) second_claim = await task_client.post( f"/api/v1/tasks/{ready_task['id']}/claim", headers=auth_headers(), json={"reason": "claim again"}, ) - assert second_claim.status_code == 409 + assert second_claim.status_code == 403, second_claim.text async def test_different_worker_cannot_start_or_read_claimed_task( @@ -3977,8 +2844,9 @@ async def test_different_worker_cannot_start_or_read_claimed_task( ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - await seed_worker_profile("worker-one") - set_dev_actor(monkeypatch, roles="worker", subject="worker-one") + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "worker-one", + ) claim = await task_client.post( f"/api/v1/tasks/{ready_task['id']}/claim", headers=auth_headers(), @@ -3986,79 +2854,63 @@ async def test_different_worker_cannot_start_or_read_claimed_task( ) assert claim.status_code == 200, claim.text - await seed_worker_profile("worker-two") - set_dev_actor(monkeypatch, roles="worker", subject="worker-two") + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "worker-two", + ) start = await task_client.post( f"/api/v1/tasks/{ready_task['id']}/start", headers=auth_headers(), json={"reason": "start"}, ) + # Retained detail/audit reads have not yet had their separate authority + # cutover; exercise their non-owner visibility rule with the accepted role. + set_dev_actor(monkeypatch, roles="worker", subject="worker-two") read = await task_client.get(f"/api/v1/tasks/{ready_task['id']}", headers=auth_headers()) audit = await task_client.get( f"/api/v1/tasks/{ready_task['id']}/audit-events", headers=auth_headers(), ) - assert start.status_code == 409 + assert start.status_code == 403, start.text assert read.status_code == 404 assert audit.status_code == 404 -async def test_operator_start_override_requires_reason_and_records_distinct_event( + + +async def test_retained_packet_reads_preserve_locked_lineage_and_redact_audit( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) - ready_task = await create_ready_task(task_client, project["id"]) - await seed_worker_profile("worker-one") - set_dev_actor(monkeypatch, roles="worker", subject="worker-one") - claim = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/claim", - headers=auth_headers(), - json={"reason": "claim"}, - ) - assert claim.status_code == 200, claim.text + started_task = await create_started_task(task_client, project["id"], monkeypatch) + worker_actor_id = actor_id("worker-one") - set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - missing_reason = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/start", - headers=auth_headers(), - json={}, - ) - assert missing_reason.status_code == 422 - - started = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/start", - headers=auth_headers(), - json={"reason": "operator verified worker started"}, + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert started.status_code == 200, started.text - assert started.json()["status"] == "in_progress" - - audit = await task_client.get( - f"/api/v1/tasks/{ready_task['id']}/audit-events", - headers=auth_headers(), - ) - assert audit.status_code == 200, audit.text - assert audit.json()[-1]["event_type"] == "task_start_override" - assert audit.json()[-1]["event_payload"]["operator_override"] is True - - -async def test_assigned_worker_submit_auto_enters_pre_review_gate( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - worker_actor_id = actor_id("worker-one") - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + # Historical creation evidence is a stored prerequisite for the read + # contract, not evidence that the retired public writer still executes. + async with db_session.get_session_factory()() as session: + stored = await session.get(Submission, submission_id) + stored_task = await session.get(WorkstreamTask, started_task["id"]) + assert stored is not None and stored_task is not None + actor = ActorContext( + actor_id=worker_actor_id, external_subject="worker-one", + external_issuer="flow-test", roles=("worker",), claim_snapshot={}, + auth_source="dev_mock", is_dev_auth=True, + ) + service = TaskService(session) + await service._write_task_audit( + actor, stored_task, event_type="submission_created", + from_status="in_progress", to_status="submitted", reason=None, + event_payload=service._submission_audit_payload(stored), + ) + await session.commit() + response = await task_client.get( + f"/api/v1/submissions/{submission_id}", headers=auth_headers(), ) - - assert response.status_code == 201, response.text + assert response.status_code == 200, response.text submission = response.json() assert submission["task_id"] == started_task["id"] assert submission["contributor_id"] == worker_actor_id @@ -4213,311 +3065,6 @@ async def test_assigned_worker_submit_auto_enters_pre_review_gate( assert gate_started_event["event_payload"]["requester_external_subject"] == "worker-one" -async def test_submission_schema_rejects_worker_supplied_locked_context( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload.update( - { - "contributor_id": actor_id("worker-one"), - "version": 1, - "status": "submitted", - "locked_guide_version": "malicious", - "locked_post_submit_checker_policy_id": "malicious", - "locked_post_submit_checker_policy_version": "malicious", - "locked_post_submit_checker_policy_hash": "sha256:" + "0" * 64, - "locked_post_submit_checker_policy_body": {"required_checkers": []}, - "locked_review_policy_id": "malicious", - "locked_review_policy_generation": 99, - "locked_review_policy_hash": "sha256:" + "1" * 64, - "locked_revision_policy_id": "malicious", - "locked_revision_policy_generation": 99, - "locked_revision_policy_hash": "sha256:" + "2" * 64, - "locked_payment_policy_version": "malicious", - "locked_guide_source_snapshot_id": "malicious", - "locked_guide_source_snapshot_hash": "sha256:" + "0" * 64, - "locked_effective_project_submission_artifact_policy_id": "malicious", - "locked_effective_project_submission_artifact_policy_hash": "sha256:" + "0" * 64, - "locked_pre_submit_checker_policy_id": "malicious", - "locked_pre_submit_checker_bundle_hash": "sha256:" + "0" * 64, - "runtime_parameters": {"required_artifacts": []}, - "finalized_at": "2026-06-07T00:00:00Z", - } - ) - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert response.status_code == 422 - task = await task_client.get(f"/api/v1/tasks/{started_task['id']}", headers=auth_headers()) - assert task.status_code == 200, task.text - assert task.json()["status"] == "in_progress" - - -async def test_submission_requires_assigned_worker_and_in_progress_task( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - ready_task = await create_ready_task(task_client, project["id"]) - await seed_worker_profile("worker-two") - set_dev_actor(monkeypatch, roles="worker", subject="worker-two") - - ready_response = await task_client.post( - f"/api/v1/tasks/{ready_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - - assert ready_response.status_code == 409 - - started_task = await create_started_task(task_client, project["id"], monkeypatch, "worker-one") - set_dev_actor(monkeypatch, roles="worker", subject="worker-two") - other_worker_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - - assert other_worker_response.status_code == 404 - - -async def test_pre_submit_failure_writes_audit_event_without_submission( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["evidence_items"] = [] - async with db_session.get_session_factory()() as session: - audit_ids_before = { - event.id - for event in ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "task", - AuditEvent.entity_id == started_task["id"], - ) - ) - ).scalars() - } - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert response.status_code == 422 - detail = response.json() - assert detail["code"] == "pre_submission_checker_failed" - assert detail["details"]["status"] == "failed" - assert detail["details"]["eligible_to_submit"] is False - evidence_result = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_evidence_present" - ) - assert evidence_result["status"] == "failed" - - async with db_session.get_session_factory()() as session: - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - audit_events = ( - ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "task", - AuditEvent.entity_id == started_task["id"], - ) - ) - ) - .scalars() - .all() - ) - checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() - task = await session.get(WorkstreamTask, started_task["id"]) - assert submissions == [] - new_audit_events = [event for event in audit_events if event.id not in audit_ids_before] - assert len(new_audit_events) == 1 - assert new_audit_events[0].event_type == "pre_submission_check_failed" - assert new_audit_events[0].from_status == "in_progress" - assert new_audit_events[0].to_status == "in_progress" - assert new_audit_events[0].event_payload["pre_submit_check"]["status"] == "failed" - assert new_audit_events[0].event_payload["pre_submit_check"]["eligible_to_submit"] is False - assert checker_runs == [] - assert task is not None - assert task.status == "in_progress" - - set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - audit_response = await task_client.get( - f"/api/v1/tasks/{started_task['id']}/audit-events", - headers=auth_headers(), - ) - assert audit_response.status_code == 200, audit_response.text - audit_event = next( - event - for event in audit_response.json() - if event["event_type"] == "pre_submission_check_failed" - ) - assert audit_event["event_payload"]["pre_submit_check"]["status"] == "failed" - - -async def test_submission_pre_submit_requires_specific_evidence_key( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["evidence_items"] = [ - { - "type": "note", - "label": "unrelated evidence", - "uri": "local://evidence/unrelated.txt", - "hash": "sha256:unrelated-v1", - "size_bytes": 64, - "metadata": {"policy_key": "other_evidence"}, - } - ] - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert response.status_code == 422, response.text - detail = response.json() - assert detail["code"] == "pre_submission_checker_failed" - evidence_result = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_evidence_present" - ) - assert evidence_result["status"] == "failed" - assert evidence_result["would_block_if_submitted"] is True - assert "required evidence" in evidence_result["worker_message"] - - async with db_session.get_session_factory()() as session: - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - assert submissions == [] - - -async def test_submission_pre_submit_requires_project_attestation_terms( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["worker_attestation"] = ( - "I attest this submission contains no confidential client data, credentials, secrets, " - "tokens, passwords, API keys, private source material, source code, copied platform " - "artifacts, or copied platform content." - ) - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert response.status_code == 422, response.text - detail = response.json() - assert detail["code"] == "pre_submission_checker_failed" - attestation_result = next( - result - for result in detail["details"]["results"] - if result["checker_name"] == "check_confidentiality_attestation" - ) - assert attestation_result["status"] == "failed" - assert attestation_result["would_block_if_submitted"] is True - assert "confidentiality attestation" in attestation_result["worker_message"] - - async with db_session.get_session_factory()() as session: - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - assert submissions == [] - - -async def test_submission_pre_submit_rejects_mutated_effective_policy_body( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - assert task is not None - effective_policy = await session.get( - EffectiveProjectSubmissionArtifactPolicy, - task.locked_effective_project_submission_artifact_policy_id, - ) - assert effective_policy is not None - effective_policy.effective_policy = { - **effective_policy.effective_policy, - "required_evidence": [], - } - await session.commit() - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - - assert response.status_code == 422, response.text - assert response.json()["code"] == "task_locked_context_invalid" - assert ( - response.json()["details"]["field"] - == "locked_effective_project_submission_artifact_policy_hash" - ) - - async with db_session.get_session_factory()() as session: - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() - assert submissions == [] - assert checker_runs == [] - - async def test_submission_pre_submit_rejects_hash_consistent_malformed_effective_policy( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -4714,51 +3261,6 @@ async def test_submission_pre_submit_rejects_hash_consistent_malformed_packaging assert checker_runs == [] -async def test_submission_pre_submit_rejects_mutated_compiled_checker_bundle( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - assert task is not None - pre_submit_policy = await session.get( - PreSubmitCheckerPolicy, - task.locked_pre_submit_checker_policy_id, - ) - assert pre_submit_policy is not None - pre_submit_policy.compiled_bundle = { - **pre_submit_policy.compiled_bundle, - "effective_policy_hash": "sha256:" + "0" * 64, - } - await session.commit() - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - - assert response.status_code == 422, response.text - assert response.json()["code"] == "task_locked_context_invalid" - assert response.json()["details"]["field"] == "locked_pre_submit_checker_bundle_hash" - - async with db_session.get_session_factory()() as session: - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() - assert submissions == [] - assert checker_runs == [] - - async def test_submission_pre_submit_rejects_hash_consistent_incomplete_checker_bundle( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -4790,151 +3292,45 @@ async def test_submission_pre_submit_rejects_hash_consistent_incomplete_checker_ } replacement_bundle_hash = canonical_json_hash(replacement_bundle) await delete_generated_post_submit_output_for_pre_submit( - session, - pre_submit_policy.id, - ) - pre_submit_policy.compiled_bundle = replacement_bundle - pre_submit_policy.compiled_bundle_hash = replacement_bundle_hash - await session.flush() - session.add( - generated_post_submit_output_for_pre_submit( - effective_policy=effective_policy, - pre_submit_checker_policy_id=pre_submit_policy.id, - pre_submit_checker_bundle_hash=replacement_bundle_hash, - ) - ) - await session.commit() - - task = await create_draft_task(task_client, project["id"]) - screen = await task_client.post( - f"/api/v1/tasks/{task['id']}/screen", - headers=auth_headers(), - json={"reason": "screening checklist passed"}, - ) - assert screen.status_code == 200, screen.text - response = await task_client.post( - f"/api/v1/tasks/{task['id']}/release", - headers=auth_headers(), - json={"reason": "release decision recorded"}, - ) - - assert response.status_code == 422, response.text - assert "locked project pre-submit checker policy" in response.json()["detail"] - - async with db_session.get_session_factory()() as session: - submissions = ( - (await session.execute(select(Submission).where(Submission.task_id == task["id"]))) - .scalars() - .all() - ) - checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() - assert submissions == [] - assert checker_runs == [] - - -async def test_submission_pre_submit_checker_setup_error_is_controlled( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - assert task is not None - pre_submit_policy = await session.get( - PreSubmitCheckerPolicy, - task.locked_pre_submit_checker_policy_id, - ) - assert pre_submit_policy is not None - pre_submit_policy.checker_names = ["unknown_project_checker"] - await session.commit() - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - - assert response.status_code == 422, response.text - assert response.json()["code"] == "task_locked_context_invalid" - assert response.json()["details"]["field"] == "locked_pre_submit_checker_policy_id" - - async with db_session.get_session_factory()() as session: - submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) - .scalars() - .all() - ) - assert submissions == [] - - -async def test_submission_rejects_crossed_post_submit_policy_sidecar( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) - assert task is not None - locked_body = dict(task.locked_post_submit_checker_policy_body or {}) - post_submit_policy = await session.get( - PostSubmitCheckerPolicy, - task.locked_post_submit_checker_policy_id, + session, + pre_submit_policy.id, + ) + pre_submit_policy.compiled_bundle = replacement_bundle + pre_submit_policy.compiled_bundle_hash = replacement_bundle_hash + await session.flush() + session.add( + generated_post_submit_output_for_pre_submit( + effective_policy=effective_policy, + pre_submit_checker_policy_id=pre_submit_policy.id, + pre_submit_checker_bundle_hash=replacement_bundle_hash, + ) ) - assert post_submit_policy is not None - post_submit_policy.required_checkers = [ - *post_submit_policy.required_checkers, - "check_acceptance_criteria_present", - ] - audit_ids = sorted(await session.scalars(select(AuditEvent.id))) await session.commit() + task = await create_draft_task(task_client, project["id"]) + screen = await task_client.post( + f"/api/v1/tasks/{task['id']}/screen", + headers=auth_headers(), + json={"reason": "screening checklist passed"}, + ) + assert screen.status_code == 200, screen.text response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", + f"/api/v1/tasks/{task['id']}/release", headers=auth_headers(), - json=complete_submission_payload(), + json={"reason": "release decision recorded"}, ) assert response.status_code == 422, response.text - assert response.json()["code"] == "task_locked_context_invalid" - assert response.json()["details"]["field"] == "locked_post_submit_checker_policy_body" + assert "locked project pre-submit checker policy" in response.json()["detail"] async with db_session.get_session_factory()() as session: - task = await session.get(WorkstreamTask, started_task["id"]) submissions = ( - ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) - ) - ) + (await session.execute(select(Submission).where(Submission.task_id == task["id"]))) .scalars() .all() ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() - assert sorted(await session.scalars(select(AuditEvent.id))) == audit_ids - assert task is not None - assert task.status == "in_progress" assert submissions == [] - assert task.locked_post_submit_checker_policy_body == locked_body - assert "check_acceptance_criteria_present" not in [ - entry["checker_id"] - for entry in locked_body["entries"] - if entry["classification"] == "project_required" - ] - assert "check_acceptance_criteria_present" not in [ - entry["checker_id"] for entry in locked_body["entries"] - ] - assert "check_required_files" in [ - entry["checker_id"] - for entry in locked_body["entries"] - if entry["classification"] == "platform_default" - ] - assert "check_required_files" in [entry["checker_id"] for entry in locked_body["entries"]] assert checker_runs == [] @@ -5025,15 +3421,16 @@ async def test_database_rejects_checker_run_without_post_submit_policy_context( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - submission_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + stored_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert submission_response.status_code == 201, submission_response.text + stored_response = await task_client.get( + f"/api/v1/submissions/{stored_id}", headers=auth_headers(), + ) + assert stored_response.status_code == 200, stored_response.text async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) - submission = await session.get(Submission, submission_response.json()["id"]) + submission = await session.get(Submission, stored_response.json()["id"]) assert task is not None assert submission is not None checker_run = db_models.CheckerRun( @@ -5072,19 +3469,20 @@ async def test_database_rejects_checker_run_without_post_submit_policy_context( await session.commit() -async def test_submission_versioning_creates_new_rows_and_preserves_v1( +async def test_retained_submission_versions_are_readable_without_exposing_packet_hashes( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) v1_payload = complete_submission_payload() - v1 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v1_payload, + v1_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v1_payload, + ) + v1 = await task_client.get( + f"/api/v1/submissions/{v1_id}", headers=auth_headers(), ) - assert v1.status_code == 201, v1.text + assert v1.status_code == 200, v1.text async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) assert task is not None @@ -5094,13 +3492,14 @@ async def test_submission_versioning_creates_new_rows_and_preserves_v1( v2_payload = complete_submission_payload("sha256:package-v2") v2_payload["artifact_hash_manifest"][0]["hash"] = "sha256:answer-v2" - v2 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v2_payload, + v2_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v2_payload, predecessor_id=v1_id, + ) + v2 = await task_client.get( + f"/api/v1/submissions/{v2_id}", headers=auth_headers(), ) - assert v2.status_code == 201, v2.text + assert v2.status_code == 200, v2.text first = v1.json() second = v2.json() assert second["version"] == 2 @@ -5123,7 +3522,7 @@ async def test_submission_versioning_creates_new_rows_and_preserves_v1( assert all("artifact_hash_manifest" not in submission for submission in listed.json()) set_dev_actor(monkeypatch, roles="worker", subject="worker-two") - await seed_worker_profile("worker-two") + await seed_task_test_actor("worker-two") denied = await task_client.get( f"/api/v1/submissions/{second['id']}", headers=auth_headers(), @@ -5131,7 +3530,7 @@ async def test_submission_versioning_creates_new_rows_and_preserves_v1( assert denied.status_code == 404 -async def test_submission_uses_task_locked_context_after_new_guide_activation( +async def test_retained_submission_finalization_preserves_locked_guide_after_activation( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -5154,13 +3553,14 @@ async def test_submission_uses_task_locked_context_after_new_guide_activation( assert activate_v2["guide"]["version"] == "v2" set_dev_actor(monkeypatch, roles="worker", subject="worker-one") - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + response_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), + ) + response = await task_client.get( + f"/api/v1/submissions/{response_id}", headers=auth_headers(), ) - assert response.status_code == 201, response.text + assert response.status_code == 200, response.text submission = response.json() assert "locked_guide_version" not in submission async with db_session.get_session_factory()() as session: @@ -5176,19 +3576,20 @@ async def test_submission_uses_task_locked_context_after_new_guide_activation( assert "locked_guide_source_snapshot_hash" not in task.json() -async def test_locked_submission_can_only_be_replaced_by_new_version( +async def test_retained_version_read_does_not_rewrite_prior_finalized_packet( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) v1_payload = complete_submission_payload() - v1 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v1_payload, + v1_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v1_payload, ) - assert v1.status_code == 201, v1.text + v1 = await task_client.get( + f"/api/v1/submissions/{v1_id}", headers=auth_headers(), + ) + assert v1.status_code == 200, v1.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") locked_v1 = await task_client.post( @@ -5206,13 +3607,14 @@ async def test_locked_submission_can_only_be_replaced_by_new_version( v2_payload = complete_submission_payload("sha256:package-replacement") v2_payload["summary"] = "Replacement packet after locked v1." v2_payload["artifact_hash_manifest"][0]["hash"] = "sha256:replacement-artifact" - v2 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v2_payload, + v2_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v2_payload, predecessor_id=v1_id, + ) + v2 = await task_client.get( + f"/api/v1/submissions/{v2_id}", headers=auth_headers(), ) - assert v2.status_code == 201, v2.text + assert v2.status_code == 200, v2.text assert v2.json()["version"] == 2 assert v2.json()["supersedes_submission_id"] == v1.json()["id"] fetched_v1 = await task_client.get( @@ -5230,102 +3632,15 @@ async def test_locked_submission_can_only_be_replaced_by_new_version( assert persisted_v1.artifact_hash_manifest[0]["hash"] == "sha256:answer-v1" -async def test_project_manager_cannot_submit_as_worker( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), - ) - - assert response.status_code == 403 - - -async def test_submission_rejects_nested_manifest_and_evidence_injection( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["artifact_hash_manifest"][0]["locked_guide_version"] = "v999" - payload["evidence_items"][0]["submission_id"] = "attacker-controlled" - - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert response.status_code == 422 - - -async def test_submission_rejects_signed_or_raw_external_uris( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - payload = complete_submission_payload() - payload["package_uri"] = "https://storage.example.test/package.tar?token=secret" - - signed_package_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert signed_package_response.status_code == 422 - - payload = complete_submission_payload() - payload["evidence_items"][0]["uri"] = "file:///home/worker/private/evidence.log" - raw_file_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert raw_file_response.status_code == 422 - - payload = complete_submission_payload() - payload["package_uri"] = "local://" - empty_reference_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert empty_reference_response.status_code == 422 - - payload = complete_submission_payload() - payload["evidence_items"][0]["uri"] = "local://../private/evidence.log" - traversal_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - - assert traversal_response.status_code == 422 - - -async def test_submitted_task_rejects_earlier_lifecycle_actions_without_new_audit( +async def test_submitted_task_rejects_earlier_lifecycle_actions_without_new_task_audit( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - submitted = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert submitted.status_code == 201, submitted.text audit_before = await task_client.get( f"/api/v1/tasks/{started_task['id']}/audit-events", headers=auth_headers(), @@ -5361,61 +3676,26 @@ async def test_submitted_task_rejects_earlier_lifecycle_actions_without_new_audi assert screen.status_code == 409 assert release.status_code == 409 - assert claim.status_code == 409 - assert start.status_code == 409 + # Canonical AUTH rejects the stale contributor resource before a TASK + # transition can occur; retained management commands still report conflict. + assert claim.status_code == 403, claim.text + assert claim.json()["error"]["code"] == "permission_not_granted" + assert start.status_code == 403, start.text + assert start.json()["error"]["code"] == "permission_not_granted" assert audit_after.status_code == 200, audit_after.text assert len(audit_after.json()) == len(audit_before.json()) -async def test_concurrent_submission_posts_return_clean_version_outcomes( - task_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = await create_active_project(task_client) - started_task = await create_started_task(task_client, project["id"], monkeypatch) - - async def post_submission(package_hash: str) -> int: - payload = complete_submission_payload(package_hash) - response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=payload, - ) - return response.status_code - - statuses = await asyncio.gather( - post_submission("sha256:concurrent-one"), - post_submission("sha256:concurrent-two"), - ) - listed = await task_client.get( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - ) - task = await task_client.get(f"/api/v1/tasks/{started_task['id']}", headers=auth_headers()) - - assert set(statuses).issubset({201, 409}) - assert statuses.count(201) >= 1 - assert listed.status_code == 200, listed.text - assert [submission["version"] for submission in listed.json()] == list( - range(1, statuses.count(201) + 1) - ) - assert task.status_code == 200, task.text - assert task.json()["status"] == "review_pending" - - async def test_cross_worker_cannot_list_submissions_or_audit_after_submit( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - await seed_worker_profile("worker-two") + await seed_task_test_actor("worker-two") set_dev_actor(monkeypatch, roles="worker", subject="worker-two") listed = await task_client.get( @@ -5439,12 +3719,9 @@ async def test_future_roles_cannot_view_unassigned_task_or_submissions( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles=role, subject=f"{role}-subject") task_read = await task_client.get(f"/api/v1/tasks/{started_task['id']}", headers=auth_headers()) @@ -5463,12 +3740,9 @@ async def test_database_blocks_task_locked_context_mutation_after_submission( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") guide_v2 = await task_client.post( @@ -5501,17 +3775,14 @@ async def test_finalize_submission_rejects_unfinished_task( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + stored_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) assert task is not None task.status = "in_progress" - submission = await TaskRepository(session).get_submission(created.json()["id"]) + submission = await TaskRepository(session).get_submission(stored_id) assert submission is not None submission.locked_at = None for evidence in submission.evidence_items: @@ -5520,7 +3791,7 @@ async def test_finalize_submission_rejects_unfinished_task( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") finalize = await task_client.post( - f"/api/v1/submissions/{created.json()['id']}/finalize", + f"/api/v1/submissions/{stored_id}/finalize", headers=auth_headers(), ) @@ -5534,21 +3805,18 @@ async def test_finalize_submission_rejects_unsubmitted_submission_row( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + stored_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text async with db_session.get_session_factory()() as session: - submission = await session.get(Submission, created.json()["id"]) + submission = await session.get(Submission, stored_id) assert submission is not None submission.status = "draft" await session.commit() set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") finalize = await task_client.post( - f"/api/v1/submissions/{created.json()['id']}/finalize", + f"/api/v1/submissions/{stored_id}/finalize", headers=auth_headers(), ) @@ -5562,12 +3830,13 @@ async def test_finalize_submission_rejects_invalid_locked_context( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + stored_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text + stored_response = await task_client.get( + f"/api/v1/submissions/{stored_id}", headers=auth_headers(), + ) + assert stored_response.status_code == 200, stored_response.text async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) assert task is not None @@ -5578,12 +3847,12 @@ async def test_finalize_submission_rejects_invalid_locked_context( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") finalize = await task_client.post( - f"/api/v1/submissions/{created.json()['id']}/finalize", + f"/api/v1/submissions/{stored_response.json()['id']}/finalize", headers=auth_headers(), ) assert finalize.status_code == 200, finalize.text - assert finalize.json()["finalized_at"] == created.json()["finalized_at"] + assert finalize.json()["finalized_at"] == stored_response.json()["finalized_at"] async def test_finalize_submission_rejects_non_latest_version( @@ -5592,12 +3861,9 @@ async def test_finalize_submission_rejects_non_latest_version( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - v1 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + first_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert v1.status_code == 201, v1.text async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) assert task is not None @@ -5606,16 +3872,19 @@ async def test_finalize_submission_rejects_non_latest_version( set_dev_actor(monkeypatch, roles="worker", subject="worker-one") v2_payload = complete_submission_payload("sha256:package-v2") v2_payload["artifact_hash_manifest"][0]["hash"] = "sha256:answer-v2" - v2 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v2_payload, + second_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v2_payload, predecessor_id=first_id, ) - assert v2.status_code == 201, v2.text + async with db_session.get_session_factory()() as session: + first = await session.get(Submission, first_id) + second = await session.get(Submission, second_id) + assert first is not None and second is not None + assert second.version == first.version + 1 + assert second.supersedes_submission_id == first.id set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") stale_finalize = await task_client.post( - f"/api/v1/submissions/{v1.json()['id']}/finalize", + f"/api/v1/submissions/{first_id}/finalize", headers=auth_headers(), ) @@ -5623,28 +3892,28 @@ async def test_finalize_submission_rejects_non_latest_version( assert "only latest submission version can be repair-checked" in stale_finalize.json()["detail"] -async def test_submitter_finalize_is_idempotent_after_automatic_gate( +async def test_finalization_repair_is_authorized_attributed_and_idempotent( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - v1 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert v1.status_code == 201, v1.text - premature_v2 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload("sha256:package-v2"), + # This is finalization/queue proof from a stored prerequisite. The retired + # POST is not a submission-creation path. TASK's context owner must still + # reject a revision before the task enters needs_revision. + premature_revision = await _submission_context_request_for_started_task( + started_task["id"], actor_id("worker-one"), + predecessor_submission_id=submission_id, ) - assert premature_v2.status_code == 409 - assert "in progress or needs revision" in premature_v2.json()["detail"] + async with db_session.get_session_factory()() as session: + with pytest.raises(TaskSubmissionContextUnavailable, match="task_submission_context_invalid"): + await TaskRepository(session).lock_submission_context(premature_revision) worker_repair = await task_client.post( - f"/api/v1/submissions/{v1.json()['id']}/finalize", + f"/api/v1/submissions/{submission_id}/finalize", headers=auth_headers(), json={"actor_id": "workstream-system:pre-review-gate"}, ) @@ -5652,7 +3921,7 @@ async def test_submitter_finalize_is_idempotent_after_automatic_gate( set_dev_actor(monkeypatch, roles="project_manager", subject="other-project-manager") wrong_manager_finalize = await task_client.post( - f"/api/v1/submissions/{v1.json()['id']}/finalize", + f"/api/v1/submissions/{submission_id}/finalize", headers=auth_headers(), json={"audit_actor": "workstream-system:pre-review-gate"}, ) @@ -5670,7 +3939,7 @@ async def test_submitter_finalize_is_idempotent_after_automatic_gate( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") locked = await task_client.post( - f"/api/v1/submissions/{v1.json()['id']}/finalize", + f"/api/v1/submissions/{submission_id}/finalize", headers=auth_headers(), json={"audit_actor": "client-supplied-spoof"}, ) @@ -5679,7 +3948,7 @@ async def test_submitter_finalize_is_idempotent_after_automatic_gate( assert locked_body["finalized_at"] is not None assert locked_body["evidence_items"][0]["finalized_at"] == locked_body["finalized_at"] checker_runs = await task_client.get( - f"/api/v1/submissions/{v1.json()['id']}/checker-runs", + f"/api/v1/submissions/{submission_id}/checker-runs", headers=auth_headers(), ) assert checker_runs.status_code == 200, checker_runs.text @@ -5734,13 +4003,13 @@ async def test_submitter_finalize_is_idempotent_after_automatic_gate( set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") second_lock = await task_client.post( - f"/api/v1/submissions/{v1.json()['id']}/finalize", + f"/api/v1/submissions/{submission_id}/finalize", headers=auth_headers(), ) assert second_lock.status_code == 200, second_lock.text assert second_lock.json()["finalized_at"] == locked_body["finalized_at"] repeated_checker_runs = await task_client.get( - f"/api/v1/submissions/{v1.json()['id']}/checker-runs", + f"/api/v1/submissions/{submission_id}/checker-runs", headers=auth_headers(), ) assert repeated_checker_runs.status_code == 200, repeated_checker_runs.text @@ -5774,13 +4043,17 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: raise PreReviewGateQueueError("simulated broker outage") monkeypatch.setattr(task_service_module, "enqueue_pre_review_gate", fail_enqueue) - create_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + # Exercise initial-dispatch recovery: persistence survives broker failure; + # the assertions below require the exact retained failure/claim evidence. + seeded_submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), + raise_on_dispatch_failure=False, + ) + stored_response = await task_client.get( + f"/api/v1/submissions/{seeded_submission_id}", headers=auth_headers(), ) - assert create_response.status_code == 201, create_response.text - assert create_response.json()["finalized_at"] is not None + assert stored_response.status_code == 200, stored_response.text + assert stored_response.json()["finalized_at"] is not None submissions = await task_client.get( f"/api/v1/tasks/{started_task['id']}/submissions", @@ -5790,7 +4063,7 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: assert len(submissions.json()) == 1 submission_id = submissions.json()[0]["id"] assert submissions.json()[0]["finalized_at"] is not None - assert submission_id == create_response.json()["id"] + assert submission_id == stored_response.json()["id"] async with db_session.get_session_factory()() as session: checker_runs = ( @@ -5912,13 +4185,17 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: raise PreReviewGateQueueError("simulated broker outage") monkeypatch.setattr(task_service_module, "enqueue_pre_review_gate", fail_enqueue) - create_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + # Exercise initial-dispatch recovery: persistence survives broker failure; + # the assertions below require the exact retained failure/claim evidence. + seeded_submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), + raise_on_dispatch_failure=False, + ) + stored_response = await task_client.get( + f"/api/v1/submissions/{seeded_submission_id}", headers=auth_headers(), ) - assert create_response.status_code == 201, create_response.text - assert create_response.json()["finalized_at"] is not None + assert stored_response.status_code == 200, stored_response.text + assert stored_response.json()["finalized_at"] is not None submissions = await task_client.get( f"/api/v1/tasks/{started_task['id']}/submissions", headers=auth_headers(), @@ -6013,13 +4290,17 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: "mark_pre_review_gate_enqueue_failed", miss_enqueue_failure_cas, ) - create_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + # Exercise initial-dispatch recovery: persistence survives broker failure; + # the assertions below require the exact retained failure/claim evidence. + seeded_submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), + raise_on_dispatch_failure=False, + ) + stored_response = await task_client.get( + f"/api/v1/submissions/{seeded_submission_id}", headers=auth_headers(), ) - assert create_response.status_code == 201, create_response.text - submission_id = create_response.json()["id"] + assert stored_response.status_code == 200, stored_response.text + submission_id = stored_response.json()["id"] async with db_session.get_session_factory()() as session: moved_run = await session.scalar( @@ -6058,13 +4339,9 @@ def hold_initial_enqueue(*, checker_run_id: str, requester_provenance: dict) -> return f"held:{checker_run_id}" monkeypatch.setattr(task_service_module, "enqueue_pre_review_gate", hold_initial_enqueue) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] async with db_session.get_session_factory()() as session: failed_run = await session.scalar( @@ -6121,13 +4398,9 @@ async def test_nonrepairable_failed_gate_does_not_return_success( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] async with db_session.get_session_factory()() as session: failed_run = await session.scalar( @@ -6174,14 +4447,18 @@ async def fail_run_queued_gate( "run_queued_pre_review_gate", fail_run_queued_gate, ) - create_response = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + # Exercise initial-dispatch recovery: persistence survives broker failure; + # the assertions below require the exact retained failure/claim evidence. + seeded_submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), + raise_on_dispatch_failure=False, + ) + stored_response = await task_client.get( + f"/api/v1/submissions/{seeded_submission_id}", headers=auth_headers(), ) - assert create_response.status_code == 201, create_response.text - submission_id = create_response.json()["id"] - assert create_response.json()["finalized_at"] is not None + assert stored_response.status_code == 200, stored_response.text + submission_id = stored_response.json()["id"] + assert stored_response.json()["finalized_at"] is not None async with db_session.get_session_factory()() as session: failed_run = await session.scalar( @@ -6251,13 +4528,9 @@ async def test_finalize_repairs_stale_running_pre_review_gate( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] stale_started_at = datetime.now(UTC) - timedelta(hours=1) async with db_session.get_session_factory()() as session: @@ -6332,13 +4605,9 @@ def hold_initial_enqueue(*, checker_run_id: str, requester_provenance: dict) -> return f"held:{checker_run_id}" monkeypatch.setattr(task_service_module, "enqueue_pre_review_gate", hold_initial_enqueue) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] stale_started_at = datetime.now(UTC) - timedelta(hours=1) async with db_session.get_session_factory()() as session: @@ -6425,14 +4694,14 @@ def hold_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: return f"held:{checker_run_id}" monkeypatch.setattr(task_service_module, "enqueue_pre_review_gate", hold_enqueue) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] - assert created.json()["finalized_at"] is not None + stored = await task_client.get( + f"/api/v1/submissions/{submission_id}", headers=auth_headers(), + ) + assert stored.status_code == 200, stored.text + assert stored.json()["finalized_at"] is not None assert len(enqueue_calls) == 1 assert enqueue_calls[0]["requester_provenance"] == expected_worker_requester_provenance() assert "claim_snapshot" not in enqueue_calls[0]["requester_provenance"] @@ -6505,16 +4774,13 @@ async def test_manual_checker_run_cannot_replace_queued_automatic_gate( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") manual_run = await task_client.post( - f"/api/v1/submissions/{created.json()['id']}/checker-runs", + f"/api/v1/submissions/{submission_id}/checker-runs", headers=auth_headers(), json={"trigger_reason": "manual shortcut attempt"}, ) @@ -6526,7 +4792,7 @@ async def test_manual_checker_run_cannot_replace_queued_automatic_gate( ( await session.execute( select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == created.json()["id"] + db_models.CheckerRun.submission_id == submission_id ) ) ) @@ -6554,13 +4820,9 @@ async def test_manual_checker_run_cannot_bypass_failed_automatic_gate( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] async with db_session.get_session_factory()() as session: queued_run = await session.scalar( @@ -6626,13 +4888,9 @@ async def test_queued_gate_policy_error_is_failed_and_repairable( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] async with db_session.get_session_factory()() as session: submission = await session.get(Submission, submission_id) @@ -6728,13 +4986,9 @@ async def test_queued_gate_rejects_tampered_requester_provenance( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] async with db_session.get_session_factory()() as session: queued_run = await session.scalar( @@ -6817,13 +5071,9 @@ async def test_queued_gate_fails_closed_when_lock_audit_is_missing( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] async with db_session.get_session_factory()() as session: queued_run = await session.scalar( @@ -6875,19 +5125,16 @@ async def test_stale_queued_pre_review_gate_skips_before_task_status_check( "enqueue_pre_review_gate", hold_pre_review_enqueue, ) - v1 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + v1_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert v1.status_code == 201, v1.text async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) assert task is not None task.status = "needs_revision" v1_run = await session.scalar( select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == v1.json()["id"] + db_models.CheckerRun.submission_id == v1_id ) ) await session.commit() @@ -6896,12 +5143,9 @@ async def test_stale_queued_pre_review_gate_skips_before_task_status_check( v2_payload = complete_submission_payload("sha256:package-v2") v2_payload["artifact_hash_manifest"][0]["hash"] = "sha256:answer-v2" - v2 = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=v2_payload, + v2_id = await seed_finalized_submission_for_checker_test( + started_task["id"], v2_payload, predecessor_id=v1_id, ) - assert v2.status_code == 201, v2.text result = cast(Any, run_pre_review_gate).run( v1_run.id, @@ -6917,7 +5161,7 @@ async def test_stale_queued_pre_review_gate_skips_before_task_status_check( stale_run = await session.get(db_models.CheckerRun, v1_run.id) fresh_run = await session.scalar( select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == v2.json()["id"] + db_models.CheckerRun.submission_id == v2_id ) ) audit_events = ( @@ -6948,13 +5192,9 @@ async def test_submission_finalize_guard_is_atomic( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + submission_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), ) - assert created.status_code == 201, created.text - submission_id = created.json()["id"] finalized_at = datetime.now(UTC) async with db_session.get_session_factory()() as session: @@ -6992,52 +5232,30 @@ async def test_database_enforces_unique_submission_version( ) -> None: project = await create_active_project(task_client) started_task = await create_started_task(task_client, project["id"], monkeypatch) - created = await task_client.post( - f"/api/v1/tasks/{started_task['id']}/submissions", - headers=auth_headers(), - json=complete_submission_payload(), + stored_id = await seed_finalized_submission_for_checker_test( + started_task["id"], complete_submission_payload(), + ) + stored_response = await task_client.get( + f"/api/v1/submissions/{stored_id}", headers=auth_headers(), ) - assert created.status_code == 201, created.text - body = created.json() + assert stored_response.status_code == 200, stored_response.text + body = stored_response.json() async with db_session.get_session_factory()() as session: persisted = await session.get(Submission, body["id"]) assert persisted is not None - session.add( - Submission( - id=str(uuid4()), - task_id=body["task_id"], - contributor_id=body["contributor_id"], - version=body["version"], - status="submitted", - summary="duplicate", - package_hash=persisted.package_hash, - artifact_hash_manifest=persisted.artifact_hash_manifest, - worker_attestation=persisted.worker_attestation, - locked_guide_version=persisted.locked_guide_version, - locked_post_submit_checker_policy_id=( - persisted.locked_post_submit_checker_policy_id - ), - locked_post_submit_checker_policy_version=( - persisted.locked_post_submit_checker_policy_version - ), - locked_post_submit_checker_policy_hash=( - persisted.locked_post_submit_checker_policy_hash - ), - locked_post_submit_checker_policy_body=( - persisted.locked_post_submit_checker_policy_body - ), - locked_review_policy_id=persisted.locked_review_policy_id, - locked_review_policy_generation=persisted.locked_review_policy_generation, - locked_review_policy_hash=persisted.locked_review_policy_hash, - locked_revision_policy_id=persisted.locked_revision_policy_id, - locked_revision_policy_generation=persisted.locked_revision_policy_generation, - locked_revision_policy_hash=persisted.locked_revision_policy_hash, - locked_payment_policy_version=persisted.locked_payment_policy_version, - ) - ) - with pytest.raises(IntegrityError): + task = await session.get(WorkstreamTask, persisted.task_id) + session.add(build_submission( + submission_id=str(uuid4()), task=task, contributor_id=persisted.contributor_id, + version=persisted.version, summary="duplicate", + worker_attestation=persisted.worker_attestation, + package_uri=persisted.package_uri, package_hash=persisted.package_hash, + artifact_hash_manifest=persisted.artifact_hash_manifest, + supersedes_submission_id=None, + )) + with pytest.raises(IntegrityError) as rejected: await session.commit() + assert integrity_constraint_name(rejected.value) == "uq_submissions_task_version" async def test_worker_cannot_create_screen_or_release_tasks( @@ -7069,7 +5287,9 @@ async def test_worker_cannot_create_screen_or_release_tasks( assert release.status_code == 403 -async def test_invalid_transitions_are_rejected(task_client: AsyncClient) -> None: +async def test_invalid_transitions_are_rejected( + task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, +) -> None: project = await create_active_project(task_client) task = await create_draft_task(task_client, project["id"]) @@ -7078,6 +5298,9 @@ async def test_invalid_transitions_are_rejected(task_client: AsyncClient) -> Non headers=auth_headers(), json={"reason": "release"}, ) + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "draft-task-submitter", + ) start_from_draft = await task_client.post( f"/api/v1/tasks/{task['id']}/start", headers=auth_headers(), @@ -7085,7 +5308,14 @@ async def test_invalid_transitions_are_rejected(task_client: AsyncClient) -> Non ) assert release_from_draft.status_code == 409 - assert start_from_draft.status_code == 409 + assert start_from_draft.status_code == 403 + assert start_from_draft.json()["error"]["code"] == "permission_not_granted" + async with db_session.get_session_factory()() as session: + unchanged = await session.get(WorkstreamTask, task["id"]) + assert unchanged.status == "draft" and unchanged.assigned_to is None + assert await session.scalar(select(TaskAssignment).where( + TaskAssignment.task_id == task["id"], + )) is None with pytest.raises(InvalidTaskTransition): ensure_allowed_transition("unknown", "ready") @@ -7093,8 +5323,8 @@ async def test_invalid_transitions_are_rejected(task_client: AsyncClient) -> Non async def test_database_enforces_one_active_assignment_per_task(task_client: AsyncClient) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - first_contributor_id = await seed_worker_profile("assignment-contributor-one") - second_contributor_id = await seed_worker_profile("assignment-contributor-two") + first_contributor_id = await seed_task_test_actor("assignment-contributor-one") + second_contributor_id = await seed_task_test_actor("assignment-contributor-two") async with db_session.get_session_factory()() as session: session.add_all( @@ -7124,8 +5354,8 @@ async def test_released_assignment_does_not_block_new_active_assignment( ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - first_contributor_id = await seed_worker_profile("released-contributor-one") - second_contributor_id = await seed_worker_profile("released-contributor-two") + first_contributor_id = await seed_task_test_actor("released-contributor-one") + second_contributor_id = await seed_task_test_actor("released-contributor-two") async with db_session.get_session_factory()() as session: session.add_all( diff --git a/docs/engineering/authorization_activation_custody.md b/docs/engineering/authorization_activation_custody.md index d43deb26c..ba24674fd 100644 --- a/docs/engineering/authorization_activation_custody.md +++ b/docs/engineering/authorization_activation_custody.md @@ -7,9 +7,10 @@ This document explains activation custody; historical sequences below do not restart completed work or create another contribution-permission system. The canonical [authorization specification](../spec_authorization_service.md) and typed runtime catalogue define the registered facts and availability. -After CP03B, AUTH-12B2 and CP05, the catalogue has 73 PermissionIds, 111 ActionIds, -67 active actions and 44 planned actions. Verify the then-current registry -when implementing rather than treating an older count as a future gate. +Verify the current typed registry when implementing rather than treating +historical catalogue totals as an activation gate. TASK claim/start and its +work-context operations now use canonical authority; that does not activate +remaining planned submission, review or recovery operations. Historical entry evidence is preserved in the [original custody record](../../.commitrail/initiatives/WS-AUTH-001/pre-cutover/ACTIVATION_CUSTODY.md). @@ -77,9 +78,8 @@ gated `artifact.verification_job.retry` remains planned and cannot be activated by read/status proof. The historical transfer added no migration because owner and availability are typed metadata. WS-XINT-002-01 -reconciles PostgreSQL parity through migration `0036`. After CP03B, AUTH-12B2 and CP05 activation, the -current catalogue has 73 PermissionIds, 111 ActionIds, 67 active actions, and -44 planned actions. Its closed registry contains fifteen service identities: +historically reconciled PostgreSQL parity through migration `0036`, now folded +into the v0.1 baseline. Its closed registry contains fifteen service identities: fourteen action-bearing identities with twenty-three matrix memberships plus the target-only `workstream.compensation.adapter` identity. CP01A registered four initially unavailable adapter-binding actions and CP01B diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 080218dd0..c10ac6a98 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -5,9 +5,11 @@ This runbook assigns ownership and stop conditions for the staged WS-AUTH-001 authorization rollout. The verified-token configuration and evidence commands are executable contracts. Canonical actor resolution, actor-self authorization, -one-time bootstrap, and administrative grant APIs are active through AUTH-08; -later actor lifecycle and project-grant sections remain staged until their -owning implementation chunks. +one-time bootstrap, administrative and project grants, and actor/link lifecycle +controls are implemented. TASK claim, start and work-context operations use +canonical project authority. Other feature activations remain operation-specific; +consult `docs/roadmap_status.md` rather than inferring availability from a grant +or catalogue entry. ## Ownership @@ -107,12 +109,14 @@ python3 scripts/check_markdown_links.py git diff --check ``` -During the compatibility period, `/api/v1/auth/me` uses only the verified -issuer/subject plus bounded legacy roles. It does not copy issuer email or -display name into actor storage or responses, so both response fields remain -`null`. Consumers must not treat token identity metadata or legacy workflow -eligibility as profile or authorization truth. Human-owned display data is -written only through `PATCH /api/v1/actors/me`. +Use `/api/v1/actors/me` for canonical actor profiles and +`PATCH /api/v1/actors/me` for human-owned display data. The still-present +`/api/v1/auth/me` route is obsolete and is being removed by the separate guide +work; it is not an alternative profile or authority contract. Task +claim/start/work-context require current canonical authority and the applicable +exact project grant. Token claims and eligibility rows are not authority. +The API drill now ends its task journey at that supported +public boundary; hidden submission creation has separate proof. ## Request And Error Context @@ -207,17 +211,16 @@ the database and install `0001_v01_baseline`. Operators must never infer a service identity from subject syntax, email, display name, token role, or adapter provenance. -Git history for the completed AUTH-06 change records the exact deprecated -compatibility identifier. That temporary, -enumerated intake route writes only `LegacyWorkflowEligibility` and cannot -create a grant or change a canonical profile. Its direct compatibility -consumers are assigned-submitter claim, assigned-submitter start, and submission -intake. Operator start override does not use the bridge. Current -ARCH-03B/03C remove the replacement task claim/start consumers and activate -their exact authority. Canonical admission-backed Submission already has its -own hidden path; ARCH-02I removes legacy public reachability after its full -prerequisites, and CP09 removes dead economic schema only after zero consumers. -Historical broad AUTH-13/14 are not additional implementation lanes. +The self-activation profile route and TASK eligibility bridge have been +removed. Claim and start require canonical actor/lifecycle checks and an active +Submitter grant for the exact project; an authorized Operator start override +uses its explicit operation and reason. Retained eligibility rows do not grant +TASK authority and have not been deleted. The old public submission-packet POST +is also removed. Canonical admission-backed Submission creation remains hidden; +its command validates current authority, exact assignment and locked policy +lineage before consuming ART admission in the same transaction. Public creation +and the remaining management/read-route cutovers remain separate work, not +capabilities implied by this retirement. ## Contributor Attribution Runtime Guard @@ -247,13 +250,20 @@ rejects a missing profile with SQLSTATE `23503` and a service profile with `23514`. Suspended and deactivated human profiles remain valid historical references. The v0.1 baseline has no downgrade path. -Claim and submission also revalidate current identity inside their mutation -transaction in lock order ActorProfile, exact issuer/subject identity link, -task, active assignment. An inactive or non-human identity returns HTTP 403 -`active_contributor_required`. Missing, mismatched, database-unavailable, or -lock-failed canonical identity state rolls back and returns retryable HTTP 503 -`contributor_identity_unavailable`. These responses are identity eligibility, -not permission decisions; grant and resource authorization remain separate. +Claim, start and work-context use canonical AUTH. TASK locks the task and +active assignment before AUTH locks the current ActorProfile, exact identity +link and applicable grant; mutation locks remain held through the transaction. +Contributor commands require an active exact-project Submitter grant, not a +token role or an eligibility row. The separate Operator start override +requires its explicit permission and a reason. AUTH denials return HTTP 403 +`permission_not_granted`; database failures roll back with retryable HTTP 503 +`task_authority_unavailable`. Initial identity resolution may reject a request +before command execution under its own identity-error contract. + +Admission-backed Submission creation remains hidden and uses its existing +TASK-first context/assignment and AUTH transaction participants. The old public +packet POST and self-activated contributor-profile endpoint are removed. Stored +contributor references and retained submission reads are preserved. ## PostgreSQL Rate Controls @@ -497,8 +507,9 @@ v0.1 baseline. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 73 PermissionIds, 112 ActionIds, 68 active actions, and -44 planned actions. CP01A added four initially unavailable adapter-binding actions under +Catalogue entries and explicit runtime composition determine availability; +a planned action is not activated by its presence in the catalogue. +CP01A added four initially unavailable adapter-binding actions under `WS-ARCH-001-CP01A` custody; it adds no evaluator, identity, grant, service matrix row, route, or activation. CP01B registered five initially unavailable `contribution.policy.*` actions with the same non-activation guarantees. CP05 diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index 9aaaf9bcc..344c9e6bb 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -16,6 +16,12 @@ phase receives only a strict `workstream_test_<12 lowercase hex>` database and a ## Local PostgreSQL diagnostic +Use PostgreSQL 16, matching Backend CI, for reset-schema fingerprint checks. +Catalog identity rendering can differ across major versions even when the +schema is equivalent. A changed fingerprint requires comparing the actual +schema objects on the CI engine; never bypass the check or accept an additional +hash merely to make a different local engine pass. + This legacy sequential command checks PostgreSQL provisioning and cleanup. It is not complete full-suite proof because it does not start or bind a MinIO provider. Use the hosted semantic-lane workflow below for authoritative @@ -46,6 +52,14 @@ prevent cleanup; recover manually with the database provisioning credential, tar ## Candidate coverage floor +All coverage collection uses `backend/pyproject.toml` with +`concurrency = ["thread", "greenlet"]`. SQLAlchemy async operations switch +greenlets within a thread; default thread-only tracing can assign executed +lines to the wrong source file. Do not override that setting in local or +hosted coverage commands. The coverage-contract suite checks actual line +attribution across SQLAlchemy async switches using the repository configuration. +This setting changes measurement, not test selection, exclusions or floors. + `coverage_policy.py --compute-floor` is a read-only preparation command. Point `--coverage-json` at temporary complete-app coverage JSON; the command validates the application-file inventory and prints the exact statement percentage @@ -74,6 +88,19 @@ WORKSTREAM_DATABASE_URL='postgresql+asyncpg://USER:PASSWORD@localhost:5433/works Do not use `WORKSTREAM_ALLOW_NONLOCAL_E2E_DATABASE` for ordinary proof. +The public task portion of this drill ends at project-authorized claim/start. +It checks that revoked grants deny work and explicitly issues fresh authority +before continuing. The self-activated eligibility endpoint and JSON-packet +submission POST are removed. A passing drill does not certify hidden +admission-backed Submission creation, finalization or post-submit routing. +Those owners require their own bounded integration evidence. + +Checker, finalization and review persistence tests may use the explicit stored +Submission fixture in `backend/tests/submission_fixtures.py`. It seeds retained +packet prerequisites and runs the existing finalization/enqueue owners; it is +not a public API or ART-admission success simulation. Tests of new Submission +creation must use the real admission-backed command, not this fixture. + If provisioning fails, confirm the local PostgreSQL provisioning credential can create/drop databases and roles, terminate owned sessions, and reach the named admin database. Diagnostics omit credentials. ## Hosted semantic-lane full-suite proof diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index 9b397a582..9e1f64b0b 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -249,6 +249,12 @@ post-submit checker policy reference. ### Task Screening Gate +The following is the target v0.1 gate. Complete ContributionPolicyVersion +propagation into Task, TaskAssignment, Submission and ReviewLease is still +planned; do not treat this checklist as proof that the current release operation +enforces that lock. See [the capability ledger](roadmap_status.md) for delivered +boundaries and remaining work. + A task cannot move to `READY` until the task contract is complete, the guide version is locked, submission artifact requirements are clear, checker/review/revision policy versions and the guide-bound @@ -261,9 +267,13 @@ After screening and release, contributors use `GET /api/v1/tasks/{task_id}/work-context` for the locked guide and lifecycle context and `GET /api/v1/tasks/{task_id}/submission-requirements` for the exact artifact, evidence, storage, packaging, hash, and attestation requirements. -Covered Project Managers and explicitly authorized Operator/Audit projections use -`GET /api/v1/tasks/{task_id}/locked-context` when support or a live API drill -needs full locked provenance without database inspection. +Covered Project Managers use +`GET /api/v1/projects/{project_id}/tasks/{task_id}/work-context` for their +canonical exact-project projection. The retained +`GET /api/v1/tasks/{task_id}/locked-context` route still uses token-role +checks for either the `admin` token role or the `project_manager` token role; +it is not a canonical Operator/Audit projection. +Its authorization cutover remains planned. ### Submission Quality Gate @@ -278,6 +288,9 @@ External origin qualification and webhook drop notifications are future adapter ## Task Release Checklist +This is the target release checklist, including the pending contribution-policy +lock described above; it is not an inventory of current runtime guards. + Before moving a task to `READY`: - task belongs to project @@ -295,6 +308,9 @@ Before moving a task to `READY`: ## Ready Gate +The complete gate below is the target v0.1 contract. Its contribution-policy +lock remains pending, as noted in Task Screening Gate. + A task cannot move to `READY` just because it has text. The ready gate confirms: @@ -312,6 +328,11 @@ If the ready gate fails, the task remains `DRAFT`. ## Submission Intake Checklist +This is the target contributor handoff. The obsolete public packet-creation POST +has been removed; canonical admission-backed creation remains hidden and does +not yet finalize or enqueue evaluation automatically. Do not use these intended +steps as a public API availability claim. + Before locking a submission packet: - task is assigned to submitter @@ -323,15 +344,18 @@ Before locking a submission packet: - effective project submission artifact policy is loaded - generated project pre-submit checker policy runs - failed submission-bundle preparation returns `pre_submission_checker_failed` with bounded same-request status, eligibility, and pass/fail/warning details -- until deferred WS-ARCH-001-02I, the frozen legacy preflight endpoint remains - non-authoritative; after every submission context and downstream prerequisite - is live, the cutover leaves no standalone endpoint or client-owned manifest - that can reproduce the authoritative result +- the retained preflight endpoint is non-authoritative; the completed cutover + must leave no standalone endpoint or client-owned manifest that can reproduce + the authoritative result - no submission row is created until blocking pre-submit checks pass - successful submission creation stamps the immutable submission boundary and queues the Celery pre-review gate -- `/finalize` is an Operator repair/requeue endpoint under - `operations.submission_gate.repair` for an already locked submission; it is - not the normal contributor handoff +- the intended Operator repair/requeue operation for an already locked submission + is `operations.submission_gate.repair`, not the normal contributor handoff. + That canonical action remains planned. The retained + `/api/v1/submissions/{submission_id}/finalize` route still uses token-role + checks for either the `admin` token role or the `project_manager` token role; + it must not be described as that activated + Operator operation ## Reviewer Simulation Gate diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index e64d7c267..94e8e25a3 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -136,10 +136,10 @@ cannot be reused as post-submission review-gate evidence. See the | Project Guide source custody | **Live foundation** | Project Manager original-document uploads; immutable metadata snapshots; exact run-scoped reads; S3-backed originals and isolated agent document inspection | Carry the same document generation through manager approval and guide activation; prove each enabled document reader | | Unified Project Guide compilation | **Live automatic draft/findings setup** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization | Add manager proposal review, correction, approval and manual rerun; add deterministic post-submit projection and one checker-service port | | Contribution policy administration | **Hidden and proven** | Finance Authority adapter-binding lifecycle; ContributionPolicy read/create/update/publish/retire with exact Finance Authority; immutable operation and event history | Expose selected-policy validation, bind one published complete version to the active guide generation | -| Task readiness and claim | **Foundation plus planned replacement** | Task records, lifecycle guards, assignments, locked work context, public owner facts | A task must inherit the guide-bound ContributionPolicyVersion before `READY`; claim copies the prepared task context into TaskAssignment without a current-policy lookup; activate exact task authority | +| Task readiness and claim | **Foundation with grant-backed contributor commands** | Task records, assignments and locked work context; claim/start/contributor context use exact-project Submitter grants; separate manager context and system-Operator start | Bind the guide's ContributionPolicyVersion before `READY` and carry it through TaskAssignment without a current-policy lookup; finish ready queues, remaining management/read authority and durable assignment invalidation | | 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 | Connect only the active unified guide/checker lineage and complete the later public admission-only cutover | | Pre-submission intake checking | **Hidden and proven; unified-guide integration remains** | Separate versioned pre-submission catalogue, locked effective-plan compilation, platform/project checks during continuous preparation, and blocking feedback before Submission creation | Connect approved unified-guide pre-submit policy lineage through task/assignment preparation and complete the canonical public cutover; passing intake must never substitute for post-submit evaluation | -| Immutable Submission creation | **Hidden and proven** | Contributor preparation authority; atomic admission consumption; TASK-owned Submission creation; fixed-service artifact binding; replay/concurrency/rollback proof | Stamp the assignment's exact ContributionPolicyVersion and unified policy lineage; remove the legacy Submission path only after remediation and review prerequisites are ready | +| Immutable Submission creation | **Hidden foundation; public packet creation retired** | Contributor preparation authority; atomic admission consumption; TASK-owned admission-backed creation; fixed-service artifact binding; replay/concurrency/rollback proof | Stamp the assignment's exact ContributionPolicyVersion and complete unified policy lineage; 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, hidden phase contracts and structural-handler conformance; existing pre-review and materialization foundations | Connect the unavailable phase port to durable execution; evaluate the exact Submission against its locked policy; persist one durable current superseding result; activate fixed services; automatically dispatch it and publish 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 | diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index cfb98d382..fef4bf605 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -284,7 +284,10 @@ producing 111 rows with 61 active and 50 planned actions. AUTH-12B2 then activates exact setup finalization, yielding 62 active and 49 planned actions without adding a row. CP05 activates the five existing ContributionPolicy actions, yielding 67 active and 44 planned actions. POL-04B1 adds the automatic compilation -request action, making the current totals 112 actions: 68 active and 44 planned. Only active human +request action, historically making 112 actions: 68 active and 44 planned. +These are activation-history counts, not the current registry census. The +current typed catalogue also includes the TASK project-authority cutover. +Only active human Finance Authority with system or exact-project scope is eligible. The explicit CON adapter uses serialized reads and transaction-bound PREP for mutations; committed replay requires fresh read authority. Registration custody remains @@ -1292,16 +1295,23 @@ The two collection routes return and transactionally bind at most the newest 100 canonical rows in deterministic newest-first order. Older retained records remain available only through their exact individually authorized read route. -`WS-AUTH-001-CONTRIBUTOR-FOUNDATION` adds no permission or authorization path. -It clean-cuts TaskAssignment and Submission attribution to `contributor_id`, -binds both fields to canonical human ActorProfiles in PostgreSQL, and exposes -one actor-owned transaction participant for claim and submission. The -participant locks the exact profile and verified issuer/subject link, requires -both to be active human identity state, returns no identity or authority data, -and runs after coarse legacy role admission but before resource locks. A -non-human or inactive identity returns `active_contributor_required`; missing, -mismatched, or unavailable canonical identity state returns retryable -`contributor_identity_unavailable`. +`WS-AUTH-001-CONTRIBUTOR-FOUNDATION` established TaskAssignment and Submission +`contributor_id` references to canonical human ActorProfiles in PostgreSQL. +The task-project-grant authorization change replaces its exclusive write-guard +wrapper and self-activated eligibility bridge with existing canonical AUTH. +TASK locks the task and active assignment before AUTH locks the exact current +profile, identity link and applicable grant. Claim/start/contributor context +require an active exact-project Submitter grant; manager context and reasoned +Operator start use their separate canonical permissions. Token role strings +do not authorize these operations. Command denials use +`permission_not_granted`; database unavailability rolls back with retryable +`task_authority_unavailable`. Identity resolution retains its own earlier +failure contract. + +The public JSON packet-creation POST is removed, not aliased or replaced by a +second authorization path. Existing admission-backed creation stays hidden, +uses TASK-first locked context and canonical submission authority, and preserves +atomic ART consumption. Retained contributor data and submission reads remain. ## Migration And Compatibility diff --git a/docs/spec_chunk_4_task_queue_assignment.md b/docs/spec_chunk_4_task_queue_assignment.md index 2f8edf249..c2c114b14 100644 --- a/docs/spec_chunk_4_task_queue_assignment.md +++ b/docs/spec_chunk_4_task_queue_assignment.md @@ -1,162 +1,114 @@ -# Chunk 4: Task Queue And Assignment - -## Scope - -This chunk adds the first task queue backend module. - -It covers: - -- task records under active projects -- locked guide and policy context during task screening before release to `READY` -- worker actor profile records -- reviewer actor profile records -- assignment records -- lifecycle guards from `DRAFT` through `IN_PROGRESS` -- audit events for task status changes -- skill tags on tasks and actor profiles - -## Non-Scope - -This chunk does not implement: - -- submission packets -- evidence items -- artifact storage -- checker runs -- human review decisions -- revision replay execution -- contribution records -- payment execution -- reputation calculation -- frontend screens - -## Expected Modules - -- `backend/app/modules/tasks/models.py` -- `backend/app/modules/tasks/repository.py` -- `backend/app/modules/tasks/schemas.py` -- `backend/app/modules/tasks/service.py` -- `backend/app/modules/tasks/router.py` -- `backend/app/modules/tasks/lifecycle.py` -- `backend/alembic/versions/0003_task_queue_assignment.py` -- `backend/tests/test_tasks.py` - -Shared wiring: - -- `backend/app/db/models.py` -- `backend/app/api/router.py` - -## Data Model Impact - -Current tables after `WS-POL-001-11`: - -- `actor_identities` -- `actor_profiles` -- `workstream_tasks` -- `task_assignments` -- `audit_events` - -Note: `WS-POL-001-11` supersedes the separate worker/reviewer profile storage -from this earlier chunk. Worker and reviewer profile behavior moves to the -shared `ActorProfile` model, while task assignment and audit records keep using -stable actor ids. - -Task records store: - -- project id -- locked guide version -- locked guide source snapshot id/hash -- locked effective project submission artifact policy hash -- locked pre-submit checker bundle hash -- locked post-submit checker policy version -- locked review policy version -- locked revision policy version -- locked payment policy version -- task source metadata -- task content fields -- skill tags -- base amount, currency, and payout type -- current lifecycle status -- assigned worker id - -Assignments enforce one active worker per task in v0.1. Project policies that allow multiple workers are later work. - -Audit events store actor-attributed status changes with Flow subject, issuer, roles, claim snapshot, auth source, dev-auth marker, transition reason, and structured event payload containing locked guide/policy context or assignment identifiers where relevant. - -## API Impact - -New endpoints: - -- `POST /api/v1/projects/{project_id}/tasks` -- `GET /api/v1/tasks/{task_id}` -- `POST /api/v1/tasks/{task_id}/screen` -- `POST /api/v1/tasks/{task_id}/release` -- `POST /api/v1/tasks/{task_id}/claim` -- `POST /api/v1/tasks/{task_id}/start` -- `GET /api/v1/tasks/{task_id}/audit-events` -- `POST /api/v1/workers/me/profile` - -Routers stay thin. Services own authorization, lifecycle checks, locked context stamping, assignment rules, and audit writes. - -## Lifecycle Impact - -Implemented transitions: +# Task Records and Assignment + +## Current boundary + +Workstream is developing its first, unreleased v0.1. This specification covers +the existing task-record and assignment foundation, including the bounded +[project-grant authorization replacement](../.commitrail/changes/task-project-grant-authorization.md). +It does not claim the complete task queue, contribution-policy lineage, +submission public cutover, or authority-invalidation worker is delivered. +The [capability ledger](roadmap_status.md) distinguishes those remaining owners. + +## Records and ownership + +- `ActorProfile` and `ActorIdentityLink` are canonical actor and external + identity records. Identity admission is not permission to work. +- AUTH owns `ProjectRoleGrant`, actor/link lifecycle and permission decisions. + Submitter and reviewer are project roles, not separate worker profiles. +- `WorkstreamTask` stores the project, source and work description, state, + assigned contributor and locked guide/policy references. +- `TaskAssignment` records the actual contributor and enforces one active + assignment per task. +- Shared audit evidence records authorized transitions. Claim/start evidence + identifies the canonical actor, assignment and exact authorization decision; + it does not copy token roles or claim snapshots as authority. + +Removing an obsolete endpoint does not delete retained rows. Remaining +management/read and checker consumers must be traced before retiring shared +identity, policy or submission storage. + +## Public task surfaces + +Contributor commands and work context use canonical project authority: + +| Surface | Authority | +|---|---| +| `POST /api/v1/tasks/{task_id}/claim` | Active same-project Submitter; ready, unassigned task | +| `POST /api/v1/tasks/{task_id}/start` | Active same-project Submitter; exact own active assignment | +| `GET /api/v1/tasks/{task_id}/work-context` | Active same-project Submitter; ready unassigned task or exact own assignment | +| `GET /api/v1/projects/{project_id}/tasks/{task_id}/work-context` | Covered Project Manager; exact route project and task | +| `POST /api/v1/operations/tasks/{task_id}/start` | System Operator; another contributor's active assignment and nonblank reason | + +The older task-management foundation also retains create, detail, screen, +release, submission-requirements, locked-context and audit reads. Their broader +replacement and projection contracts remain owned by ARCH-03B/03C; this bounded +repair does not certify those routes as fully cut over. + +There is no self-activation endpoint. A contributor cannot acquire permission +by creating a worker profile, supplying skill tags, or presenting a token role. +There is no public JSON-packet submission creation route. The existing +`GET /api/v1/tasks/{task_id}/submissions` remains a read, not evidence that POST +creation is usable. Admission-backed Submission creation stays hidden until +its separate canonical public integration is complete. + +## Transitions and locked lineage + +Stored task states use the canonical lowercase tokens: ```text -DRAFT -> SCREENING -SCREENING -> READY -READY -> CLAIMED -CLAIMED -> IN_PROGRESS +draft -> screening -> ready -> claimed -> in_progress ``` -Rules: - -- `DRAFT -> SCREENING` requires active project guide context and complete task source, description, acceptance, and rejection fields, then locks guide and policy versions on the task. -- `SCREENING -> READY` requires that guide, checker, review, revision, and payment policy context be locked. -- `READY -> CLAIMED` creates an active assignment and blocks a second active assignment. -- `CLAIMED -> IN_PROGRESS` requires an active assignment for the actor or an authorized operator role. -- every status change writes an audit event. - -## Security/Auth Impact - -Workstream still verifies external Flow actor context only. It does not add login, signup, password reset, password storage, or primary auth sessions. - -Role expectations: - -- admin and project manager can create, screen, release, and inspect tasks -- workers can claim ready tasks only when an active worker profile already exists -- workers create or refresh their own active profile through - `POST /api/v1/workers/me/profile` before claim; identity fields come from the - verified Flow token and the request may only supply normalized skill tags -- worker claim does not self-create or overwrite worker eligibility skill state -- workers can read ready tasks and their own assigned tasks -- admins and project managers can start a claimed task for operational testing, but do not create worker assignments for themselves through the claim path -- non-assigned operator starts require a non-empty override reason in audit -- audit API responses redact persisted claim snapshots unless a later privileged endpoint explicitly exposes them -- audit events persist the actor audit context from the verified token - -## Tests Required - -- migration upgrade/downgrade includes Chunk 4 tables -- task model metadata includes one-active-assignment constraint -- task can be created in `draft` -- task cannot enter `screening` without required fields -- task enters `ready` only after guide and policy context are locked -- task can move `ready -> claimed -> in_progress` -- second claim is rejected while an active assignment exists -- status transitions write actor-attributed audit events -- unauthorized actors are rejected for project-manager actions - -## Conditions Of Satisfaction - -- backend tests pass against Postgres -- docstring coverage remains above threshold -- stale wording scan passes for docs changed in this chunk -- Markdown link check passes for docs changed in this chunk -- senior engineering, QA/test, and security/auth verification complete or concerns are recorded for operator review - -## Reviewer Agents Required - -- senior engineering -- QA/test -- security/auth +- Screening requires the existing project/guide and task-content prerequisites + and stamps locked policy references; release requires complete locks. +- Claim validates the task's locked context and creates one active assignment. +- Normal start cannot borrow another contributor's assignment. +- Operator start does not reassign ownership or create a contributor grant. +- Existing locked context remains tied to the attempt. A later guide or + policy publication alone is not permission to rewrite that context. +- The remaining readiness work must bind the guide's ContributionPolicyVersion + before work becomes claimable and copy it through assignment and Submission. + It must not add a fresh CON lookup during claim. + +Task and assignment are locked first, followed by canonical actor, identity +link and applicable grant revalidation and locking. This matches hidden +Submission creation's lock order. Authorization consumes the exact locked +facts before writes. Product writes and their audit evidence commit or roll +back together. + +Revocation immediately prevents subsequent contributor commands. Closing an +existing assignment and returning a task to the ready queue through durable +invalidation remains separately planned; a denied start is not proof that +such an invalidation worker has run. + +## Work-context hints + +Hints describe the current supported contributor command, not permission +tokens and not the full planned workflow: + +- Ready and unassigned: `claim`. +- Claimed with the caller's exact active assignment: `start`. +- Otherwise: no contributor command hint. +- Management context does not advertise contributor commands. +- No `submit` or pre-submit execution hint is advertised by this surface while + the canonical public submission integration remains hidden. + +Pre-submission intake failures prevent Submission creation. Post-submission +evaluation concerns the submitted work and supplies evidence for +policy-governed routing; it does not own final acceptance. + +## Required verification + +- Real exact-project grants permit the supported commands without a worker + token role; missing, revoked, reviewer-only and foreign-project grants deny. +- Suspended/deactivated actors and revoked or substituted identity links deny. +- Non-owner starts, inconsistent assignments and invalid locked context deny. +- System Operator authority is distinct from Project Manager and token roles. +- Concurrent claims have one winner; revocation and commands serialize. +- Audit/storage failure rolls back task, assignment and authorization evidence. +- Work-context hints match current authority, state and assignment. +- Removed endpoints, activation schemas and runtime bridge have no consumers. +- Required intake, immutable lineage and retained-data regressions survive + fixture migration; no helper fabricates public submission success. +- Boundary checks, applicable tests, hosted coverage and focused reviews pass + before the implementation is declared ready.