From ccf485516bd8422957890f527cb460eae0a871b9 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 7 Aug 2026 06:32:55 +0100 Subject: [PATCH 1/4] feat(auth): activate fixed-service policy derivation --- .../CHUNK_MAP.md | 2 +- .../STATUS.md | 13 +- ...F3-submission-policy-service-derivation.md | 12 +- .../WS-AUTH-001-12F3-pr-trust-bundle.md | 124 ++++ .../0059_submission_policy_execution_claim.py | 169 +++++ .../app/modules/authorization/catalogue.py | 9 +- backend/app/modules/projects/models.py | 5 +- backend/app/modules/projects/router.py | 52 +- backend/app/modules/projects/service.py | 156 +--- .../submission_policy_mutation_repository.py | 36 +- .../submission_policy_mutation_service.py | 552 +++++++++++++- .../projects/sufficiency_mutation_service.py | 16 + backend/app/workers/project_setup.py | 71 +- backend/tests/conftest.py | 2 +- backend/tests/test_alembic.py | 4 +- backend/tests/test_authorization.py | 103 ++- backend/tests/test_projects.py | 673 +++++++++++------- docs/operations_authorization_service.md | 26 +- docs/spec_authorization_service.md | 16 +- docs/spec_chunk_3_project_guide_foundation.md | 13 +- 20 files changed, 1528 insertions(+), 526 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md create mode 100644 backend/alembic/versions/0059_submission_policy_execution_claim.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 86fac8d83..26d2c7b03 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -71,7 +71,7 @@ stopped. | `WS-AUTH-001-12F` | Submission Artifact Policy Planning Parent | L1 | Split after failed L1 pre-start review; zero activation | | `WS-AUTH-001-12F1` | Submission Policy Authority Foundation | L1 | Merged through PR #286; zero activation | | `WS-AUTH-001-12F2` | Manual Submission Policy Drafts | L1 | Merged through PR #292 as `81f281bd` | -| `WS-AUTH-001-12F3` | Fixed-Service Policy Derivation | L1 | Proposed after 12F2 | +| `WS-AUTH-001-12F3` | Fixed-Service Policy Derivation | L1 | Ready for PR; internal L1 reviews passed | | `WS-AUTH-001-12F4` | Submission Policy Approval Chain | L1 | Proposed after 12F3 | | `WS-AUTH-001-12G` | Post-Submit Checker Policy Mutation Cutover | L1 | Proposed after 12F4 | | `WS-AUTH-001-12H` | Guide Activation Cutover | L1 | Proposed after 12B2 and the owning CON clean cut | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index cb2f11f14..c209b7e61 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -124,15 +124,14 @@ setup-run binding findings were repaired. ## Active implementation chunk -`WS-XINT-002-06A`; the user started the fixed pre-submit materializer -activation after ART-04B3 and AUTH-12F2 merged. This cross-initiative chunk -activates only `artifact.pre_submit.checker_input.materialize` before ART-04C1. -AUTH-12F3 remains the next AUTH-12 successor and resumes while ART proceeds -through 04C1/04C2 after 06A merges. +`WS-AUTH-001-12F3`; implementation and required internal reviews are complete. +The fixed setup-service derivation cutover is ready for hosted CI and external +review. `WS-AUTH-001-12F4` remains proposed and must not start before 12F3 is +human-merged. ## Current review branch -`codex/ws-xint-002-06a-pre-submit-materialization`. +`codex/ws-auth-001-12f3-service-derivation`. ## Chunk status @@ -184,7 +183,7 @@ through 04C1/04C2 after 06A merges. | `WS-AUTH-001-12F` | Planning split | `codex/ws-auth-001-12f-submission-artifact-policy` | - | Combined contract failed required L1 pre-start review; parent now activates nothing and delegates to 12F1-12F4. | | `WS-AUTH-001-12F1` | Merged | `codex/ws-auth-001-12f1-submission-policy-foundation` | #286 | Submission-policy PREP, replay, provenance, and audit custody foundation merged as `5a4186cc`; zero activation. | | `WS-AUTH-001-12F2` | Merged | `codex/ws-auth-001-12f2-manual-submission-policy` | #292 | Governed Project Manager append-only manual-draft create/update cutover merged as `81f281bd`. | -| `WS-AUTH-001-12F3` | Proposed | - | - | Fixed setup-service derivation and asynchronous executor cutover. | +| `WS-AUTH-001-12F3` | Ready for PR | `codex/ws-auth-001-12f3-service-derivation` | - | Fixed setup-service derivation and asynchronous executor cutover; internal L1 reviews passed. | | `WS-AUTH-001-12F4` | Proposed | - | - | Project Manager approval and atomic effective/pre-submit policy chain. | | `WS-AUTH-001-12G` | Proposed | - | - | Post-submit checker policy approval/correction cutover after 12F4. | | `WS-AUTH-001-12H` | Proposed | - | - | Terminal guide activation after 12B2 and the owning CON clean cut. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md index 340cb0c9d..6a46dc051 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md @@ -2,7 +2,8 @@ ## Status and prerequisite -Proposed and inactive after merged 12F2. Risk: L1. +Implementation complete and ready for hosted CI/external review after merged +12F2. Risk: L1. Human merge remains required. ## Goal @@ -24,9 +25,14 @@ backend/app/modules/projects/service.py backend/app/modules/projects/setup_queue.py backend/app/modules/projects/submission_policy_mutation_service.py backend/app/modules/projects/submission_policy_mutation_repository.py +backend/app/modules/projects/models.py +backend/app/modules/projects/sufficiency_mutation_service.py backend/app/workers/project_setup.py backend/tests/test_authorization.py +backend/tests/test_alembic.py backend/tests/test_projects.py +backend/tests/conftest.py +backend/alembic/versions/0059_submission_policy_execution_claim.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md docs/operations_authorization_service.md @@ -58,6 +64,10 @@ mutation, post-submit policy, generic service authority, or ART behavior. and be `passed`, or `passed_with_warnings` with its warnings acknowledged. Missing, blocked, stale, diagnostic-only or unacknowledged-warning output denies before material or agent I/O. +- After an authorized warning acknowledgement, the same setup generation is + deterministically requeued and resumes derivation without rerunning + sufficiency; repeated recovery does not duplicate material access, agent + invocation or the policy draft. - Before material or agent I/O, the service acquires a deterministic execution fence and proves current fixed-service admission. No prepared handle crosses rollback, external material/agent work, Celery, serialization or transaction. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md new file mode 100644 index 000000000..2f0ad0fd1 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md @@ -0,0 +1,124 @@ +# PR Trust Bundle: WS-AUTH-001-12F3 + +## Chunk + +`WS-AUTH-001-12F3` — Fixed-Service Policy Derivation (L1). + +## Goal + +Activate submission-policy derivation only for `workstream.project.setup`, run +it through fresh fixed-service PREP in Celery, and remove public inline agent +derivation. + +## Human-approved intent + +Complete AUTH-12F3 end to end without weakening authorization, tests, or CI; +use GitHub Actions for the full suite and coverage proof. + +## What changed + +- Activated only `project.submission_artifact_policy.derive` for the fixed + project-setup service. +- Removed the public derive endpoint and role-based product-service seam. +- Added exact setup/lineage/provenance validation, fresh pre-I/O and final PREP, + total lock ordering, atomic decision/product evidence, and exact replay. +- Added a DB-enforced `reserved -> pending -> committed` execution-claim state + machine so process loss cannot repeat material or agent I/O. +- Made warning-acknowledgement replay retry a failed same-generation enqueue + without rerunning sufficiency. +- Updated migration parity, schema fingerprint, tests, specifications, and ops + guidance. + +## Why it changed + +Automatic setup derivation needed a closed fixed-service boundary, exact +transactional evidence, and crash-safe replay before it could be activated. + +## Design chosen + +Reuse the opaque `PreparedAuthorizationHandle` protocol and fixed-service +composition. Commit only a deterministic `reserved` execution claim before +external I/O; after I/O, reload and lock the complete lineage, consume fresh +transaction-bound authority, then atomically bind and complete replay with the +policy and setup output. + +## Alternatives rejected + +- Public or human inline agent invocation. +- Raw authorization context, serialized handles, or an ART-local evaluator. +- Advisory locking alone; it cannot survive process loss. +- Deleting durable reservations during downgrade. + +## Scope control + +No approval/effective/pre-submit mutation, post-submit policy activation, ART +behavior, generic service authority, frontend work, or CI changes are included. + +## Product behavior + +An authoritative same-generation sufficiency result permits only the fixed +setup service to derive one immutable draft. Exact committed redelivery returns +that draft without external I/O. A failed post-reservation execution remains +durably fenced. Project Managers may create manual drafts but cannot invoke the +derivation agent inline. + +## Acceptance criteria proof + +- Fixed service/action isolation and PREP integrity: authorization tests. +- Exact running/completed custody and stale-output denial: focused project tests. +- Real commit, immutable provenance/defaults, exact replay, durable failure + reservation, and zero-I/O redelivery: PostgreSQL integration test. +- Migration upgrade/downgrade and single-head integrity: isolated Alembic test. +- Removed route: OpenAPI/import-reachability project tests. + +## Tests/checks run + +- Ruff and Python compile checks passed. +- AUTH selector: 2 passed. +- Worker/replay/custody selector: 8 passed. +- CI lane integrity: 33 passed. +- PostgreSQL service/replay/crash test: 1 passed. +- Isolated Alembic round trip: 1 passed. +- Stale authorization wording, Markdown links, and `git diff --check` passed. +- Full repository coverage is intentionally delegated to hosted GitHub Actions. + +## Test delta + +Added fixed-service PREP, worker composition, stale custody, exact completed +replay, real PostgreSQL success/replay/crash recovery, route removal, catalogue +activation, migration-head, provenance, immutable-manual-update, and default +policy-floor assertions. No tests were skipped or weakened. + +## CI integrity + +No workflow, lane, lint, coverage, or package-script changes. Repository-wide +78% and changed-subsystem 90% requirements remain intact. + +## Reviewer results + +Architecture, security, QA, senior engineering, product/ops, test-delta, reuse, +docs, and CI-integrity reviews passed after findings were fixed. + +## External review + +Pending CodeRabbit and exact-head GitHub Actions checks. + +## Remaining risks + +Low: execution-fence and worker composition scaffolding parallels sufficiency; +extract a shared helper only if a third boundary establishes a stable pattern. + +## Follow-up work + +After human merge, reassess and start `WS-AUTH-001-12F4` as its own bounded +chunk. Do not start it automatically from this PR. + +## Human review focus + +Review migration 0059's one-way guards, the pre-I/O commit boundary, final +atomic PREP/evidence transaction, exact completed replay, and fixed-service-only +catalogue activation. + +## Human merge ownership + +Only the user may authorize merge of the specific PR. diff --git a/backend/alembic/versions/0059_submission_policy_execution_claim.py b/backend/alembic/versions/0059_submission_policy_execution_claim.py new file mode 100644 index 000000000..3bc9dd63e --- /dev/null +++ b/backend/alembic/versions/0059_submission_policy_execution_claim.py @@ -0,0 +1,169 @@ +"""add durable submission-policy derivation execution claims + +Revision ID: 0059_policy_execution_claim +Revises: 0058_pre_submit_evidence +Create Date: 2026-08-07 +""" + +from __future__ import annotations + +from alembic import op + + +revision = "0059_policy_execution_claim" +down_revision = "0058_pre_submit_evidence" +branch_labels = depends_on = None + + +def _install_guard(*, allow_reserved: bool) -> None: + reserved_transition = """ + if old.status = 'reserved' and new.status = 'pending' + and old.service_identity = 'workstream.project.setup' + and old.action_id = 'project.submission_artifact_policy.derive' + and (new.id,new.actor_profile_id,new.identity_link_id,new.service_identity, + new.action_id,new.idempotency_key,new.operation_id,new.project_id, + new.guide_id,new.source_snapshot_id,new.policy_id,new.setup_run_id, + new.setup_generation,new.setup_task_id,new.correlation_id,new.created_at, + new.response_json::text,new.committed_policy_id,new.committed_effective_policy_id, + new.committed_pre_submit_policy_id,new.committed_at) + is not distinct from + (old.id,old.actor_profile_id,old.identity_link_id,old.service_identity, + old.action_id,old.idempotency_key,old.operation_id,old.project_id, + old.guide_id,old.source_snapshot_id,old.policy_id,old.setup_run_id, + old.setup_generation,old.setup_task_id,old.correlation_id,old.created_at, + old.response_json::text,old.committed_policy_id,old.committed_effective_policy_id, + old.committed_pre_submit_policy_id,old.committed_at) + then + return new; + end if; + """ if allow_reserved else "" + op.execute("drop function if exists reject_submission_policy_replay_mutation() cascade") + op.execute( + f""" + create function reject_submission_policy_replay_mutation() returns trigger + language plpgsql as $$ + begin + if tg_op = 'DELETE' then + raise exception 'submission-policy replay rows cannot be deleted'; + end if; +{reserved_transition} + if old.status <> 'pending' or new.status <> 'committed' + or (new.id,new.actor_profile_id,new.identity_link_id,new.service_identity, + new.action_id,new.idempotency_key,new.request_digest, + new.resource_context_digest,new.resource_context_json::text,new.operation_id, + new.project_id,new.guide_id,new.source_snapshot_id,new.policy_id, + new.setup_run_id,new.setup_generation,new.setup_task_id, + new.correlation_id,new.created_at) + is distinct from + (old.id,old.actor_profile_id,old.identity_link_id,old.service_identity, + old.action_id,old.idempotency_key,old.request_digest, + old.resource_context_digest,old.resource_context_json::text,old.operation_id, + old.project_id,old.guide_id,old.source_snapshot_id,old.policy_id, + old.setup_run_id,old.setup_generation,old.setup_task_id, + old.correlation_id,old.created_at) + then + raise exception 'invalid submission-policy replay mutation'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger trg_submission_policy_replay_immutable before update or delete " + "on submission_policy_mutation_idempotency_records for each row " + "execute function reject_submission_policy_replay_mutation()" + ) + + +def upgrade() -> None: + op.execute( + "drop trigger submission_policy_replay_custody " + "on submission_policy_mutation_idempotency_records" + ) + op.execute( + "create constraint trigger submission_policy_replay_custody after insert or update " + "on submission_policy_mutation_idempotency_records deferrable initially deferred " + "for each row when (new.status='committed') " + "execute function validate_submission_policy_authority_custody()" + ) + op.drop_constraint( + "ck_submission_policy_replay_status", + "submission_policy_mutation_idempotency_records", + type_="check", + ) + op.drop_constraint( + "ck_submission_policy_replay_state_shape", + "submission_policy_mutation_idempotency_records", + type_="check", + ) + op.create_check_constraint( + "ck_submission_policy_replay_status", + "submission_policy_mutation_idempotency_records", + "status in ('reserved','pending','committed')", + ) + op.create_check_constraint( + "ck_submission_policy_replay_state_shape", + "submission_policy_mutation_idempotency_records", + "(status in ('reserved','pending') and response_json is null and committed_at is null " + "and committed_policy_id is null and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null) or " + "(status='committed' and response_json is not null and committed_at is not null " + "and committed_policy_id is not null and " + "((action_id='project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is not null " + "and committed_pre_submit_policy_id is not null) or " + "(action_id<>'project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null)))", + ) + _install_guard(allow_reserved=True) + + +def downgrade() -> None: + op.execute("drop function reject_submission_policy_replay_mutation() cascade") + op.execute( + "do $$ begin if exists (select 1 from " + "submission_policy_mutation_idempotency_records where status='reserved') then " + "raise exception 'cannot downgrade submission-policy execution claims with reservations'; " + "end if; end $$" + ) + op.drop_constraint( + "ck_submission_policy_replay_status", + "submission_policy_mutation_idempotency_records", + type_="check", + ) + op.drop_constraint( + "ck_submission_policy_replay_state_shape", + "submission_policy_mutation_idempotency_records", + type_="check", + ) + op.create_check_constraint( + "ck_submission_policy_replay_status", + "submission_policy_mutation_idempotency_records", + "status in ('pending','committed')", + ) + op.create_check_constraint( + "ck_submission_policy_replay_state_shape", + "submission_policy_mutation_idempotency_records", + "(status='pending' and response_json is null and committed_at is null " + "and committed_policy_id is null and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null) or " + "(status='committed' and response_json is not null and committed_at is not null " + "and committed_policy_id is not null and " + "((action_id='project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is not null " + "and committed_pre_submit_policy_id is not null) or " + "(action_id<>'project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null)))", + ) + _install_guard(allow_reserved=False) + op.execute( + "drop trigger submission_policy_replay_custody " + "on submission_policy_mutation_idempotency_records" + ) + op.execute( + "create constraint trigger submission_policy_replay_custody after insert or update " + "on submission_policy_mutation_idempotency_records deferrable initially deferred " + "for each row execute function validate_submission_policy_authority_custody()" + ) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index d045a6e76..e6a2cbf07 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -222,6 +222,7 @@ class ActionOwner(StrEnum): AUTH_12E = "WS-AUTH-001-12E" AUTH_12F = "WS-AUTH-001-12F" AUTH_12F2 = "WS-AUTH-001-12F2" + AUTH_12F3 = "WS-AUTH-001-12F3" XINT_002_06A = "WS-XINT-002-06A" AUTH_12G = "WS-AUTH-001-12G" AUTH_12H = "WS-AUTH-001-12H" @@ -487,10 +488,10 @@ def _active( PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F2, ), - _planned( + _active( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F, + ActionOwner.AUTH_12F3, ), _active( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, @@ -839,6 +840,7 @@ def _index_actions( ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, @@ -1025,7 +1027,7 @@ def _index_service_actions( ), ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F, + ActionOwner.AUTH_12F3, ), ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, @@ -1077,6 +1079,7 @@ def _index_service_actions( ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, } else ActionAvailability.PLANNED ) diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index cbb368a8b..30bdaea6e 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -280,10 +280,11 @@ class SubmissionPolicyMutationIdempotencyRecord(Base): name="ck_submission_policy_replay_principal_shape", ), CheckConstraint( - "status in ('pending','committed')", name="ck_submission_policy_replay_status" + "status in ('reserved','pending','committed')", + name="ck_submission_policy_replay_status", ), CheckConstraint( - "(status='pending' and response_json is null and committed_at is null " + "(status in ('reserved','pending') and response_json is null and committed_at is null " "and committed_policy_id is null and committed_effective_policy_id is null " "and committed_pre_submit_policy_id is null) or " "(status='committed' and response_json is not null and committed_at is not null " diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index fdc0319ae..befc31903 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -6,7 +6,7 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from app.adapters.artifacts import get_guide_artifact_ingest_command @@ -24,9 +24,6 @@ GuideArtifactIngestCommand, ) from app.modules.artifacts.authorization import get_artifact_authorization_context -from app.modules.artifacts.guide_sufficiency_material import ( - SqlAlchemyGuideSufficiencyMaterialAdapter, -) from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError from app.modules.artifacts.service import ArtifactAdmissionRelationshipError from app.modules.authorization.runtime import AuthorizationContext @@ -519,6 +516,15 @@ async def acknowledge_guide_sufficiency_warnings( resolved, prepared, key, project_id, guide_id, report_id, payload ) await (session.rollback() if outcome.replayed else session.commit()) + if outcome.response.project_setup_run_id is not None: + await dispatch_pre_submit_setup_pipeline_after_commit( + session, + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=outcome.response.source_snapshot_id, + setup_run_id=outcome.response.project_setup_run_id, + setup_generation=outcome.response.setup_generation, + ) return outcome.response except GuideSufficiencyMutationConflict as exc: await session.rollback() @@ -562,44 +568,6 @@ async def create_submission_artifact_policy( raise project_http_error(exc) from exc -@router.post( - "/{project_id}/guides/{guide_id}/source-snapshots/{source_snapshot_id}/derive-submission-artifact-policy", - response_model=SubmissionArtifactPolicyResponse, - status_code=201, - responses={ - 200: { - "model": SubmissionArtifactPolicyResponse, - "description": "Existing agent-derived submission artifact policy reused.", - } - }, -) -async def run_submission_artifact_policy_derivation_agent( - project_id: str, - guide_id: str, - source_snapshot_id: str, - response: Response, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> SubmissionArtifactPolicyResponse: - """Run Workstream's submission artifact policy derivation agent.""" - try: - result, created = await ProjectService( - session, - guide_sufficiency_material=SqlAlchemyGuideSufficiencyMaterialAdapter(session), - ).run_submission_artifact_policy_derivation_agent( - actor, - project_id, - guide_id, - source_snapshot_id, - ) - response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK - return result - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc - - @router.patch( "/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}", response_model=SubmissionArtifactPolicyResponse, diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 4f93c37ae..ee48c6994 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -24,10 +24,8 @@ from app.core.permissions import require_any_role from app.core.project_agents import get_project_guide_agent_runtime from app.interfaces.project_agents import ( - AgentFinding, GuideSourceItemMaterial, GuideSourceMaterial, - GuideSufficiencyAgentResult, PostSubmitCheckerCatalogEntry, PostSubmitCheckerPolicyCorrectionFeedback, PostSubmitCheckerPolicyDerivationContext, @@ -102,7 +100,6 @@ ProjectSetupRunResponse, RevisionPolicyResponse, ReviewPolicyResponse, - SubmissionArtifactPolicyInput, SubmissionArtifactPolicyApprove, SubmissionArtifactPolicyResponse, ) @@ -973,136 +970,6 @@ async def acknowledge_guide_sufficiency_warnings( await self._session.refresh(report) return GuideSufficiencyReportResponse.model_validate(report) - async def run_submission_artifact_policy_derivation_agent( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - source_snapshot_id: str, - ) -> tuple[SubmissionArtifactPolicyResponse, bool]: - """Run the configured policy derivation agent for a source snapshot. - - Args: - actor: Verified Flow actor context for the current request. - project_id: Project that owns the guide. - guide_id: Guide whose immutable source snapshot should be analyzed. - source_snapshot_id: Source snapshot id to derive policy from. - - Returns: - Existing or newly persisted policy plus whether it was created. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._get_project_guide(project_id, guide_id) - if guide.status != "draft": - raise GuideEditBlocked("only draft guides can derive submission artifact policies") - snapshot = await self._get_snapshot_for_guide(project_id, guide, source_snapshot_id) - await self._ensure_snapshot_is_latest(project_id, guide, snapshot) - await self.validate_source_snapshot_integrity(snapshot, PolicySetupBlocked) - sufficiency_report = await self._repo.get_sufficiency_report_for_snapshot(snapshot.id) - self._validate_sufficiency_report_allows_policy_derivation( - sufficiency_report, - snapshot, - ) - await self._validate_agent_sufficiency_report_for_derivation(sufficiency_report) - assert sufficiency_report is not None - existing = await self._repo.get_agent_derived_submission_artifact_policy_for_snapshot( - project_id, - guide.version, - snapshot.id, - ) - if existing is not None: - self._validate_agent_derived_submission_artifact_policy(existing, snapshot) - return SubmissionArtifactPolicyResponse.model_validate(existing), False - - material = await self._verified_guide_source_material( - guide, - snapshot, - sufficiency_report, - ) - runtime_report = GuideSufficiencyAgentResult( - status=REPORT_STATUS_TO_AGENT_SUFFICIENCY_STATUS[sufficiency_report.status], - findings=[ - AgentFinding.model_validate(finding) for finding in sufficiency_report.findings - ], - summary=sufficiency_report.summary, - agent_name=PROJECT_GUIDE_SUFFICIENCY_AGENT_NAME, - agent_version=PROJECT_GUIDE_SUFFICIENCY_AGENT_VERSION, - ) - await self._session.rollback() - try: - result = await self._project_agent_runtime().derive_submission_artifact_policy( - material, - runtime_report, - ) - except ProjectAgentRuntimeError: - raise AgentRuntimeUnavailable( - "submission artifact policy agent is unavailable" - ) from None - - try: - policy_input = SubmissionArtifactPolicyInput.model_validate(result.policy_body) - except ValueError as exc: - raise PolicySetupBlocked("derived submission artifact policy is invalid") from exc - policy_body = self._canonical_policy_body(policy_input.model_dump(mode="json")) - self._merge_effective_submission_artifact_policy(policy_body) - guide = await self._lock_project_guide_for_setup(project_id, guide_id) - if guide.status != "draft": - raise GuideEditBlocked("only draft guides can derive submission artifact policies") - snapshot = await self._get_snapshot_for_guide(project_id, guide, source_snapshot_id) - await self._ensure_snapshot_is_latest(project_id, guide, snapshot) - await self.validate_source_snapshot_integrity(snapshot, PolicySetupBlocked) - sufficiency_report = await self._repo.get_sufficiency_report_for_snapshot(snapshot.id) - self._validate_sufficiency_report_allows_policy_derivation( - sufficiency_report, - snapshot, - ) - await self._validate_agent_sufficiency_report_for_derivation(sufficiency_report) - existing = await self._repo.get_agent_derived_submission_artifact_policy_for_snapshot( - project_id, - guide.version, - snapshot.id, - ) - if existing is not None: - self._validate_agent_derived_submission_artifact_policy(existing, snapshot) - return SubmissionArtifactPolicyResponse.model_validate(existing), False - source_material_refs = await self._verified_source_material_refs(sufficiency_report) - policy = SubmissionArtifactPolicy( - id=str(uuid4()), - project_id=project_id, - guide_id=guide.id, - guide_version=guide.version, - source_snapshot_id=snapshot.id, - source_snapshot_hash=snapshot.bundle_hash, - policy_version=agent_submission_artifact_policy_version(snapshot.bundle_hash), - lifecycle_status="draft", - policy_body=policy_body, - policy_hash=self._hash_canonical_json(policy_body), - derivation_source=AGENT_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE, - source_material_refs=source_material_refs, - derivation_agent_name=SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_NAME, - derivation_agent_version=SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_VERSION, - created_by=actor.actor_id, - change_summary=result.change_summary, - ) - try: - policy = await self._repo.add_submission_artifact_policy(policy) - await self._session.commit() - except IntegrityError as exc: - await self._session.rollback() - existing = await self._repo.get_agent_derived_submission_artifact_policy_for_snapshot( - project_id, - guide.version, - snapshot.id, - ) - if existing is not None: - self._validate_agent_derived_submission_artifact_policy(existing, snapshot) - return SubmissionArtifactPolicyResponse.model_validate(existing), False - raise PolicySetupConflict( - "submission artifact policy conflicted with concurrent setup; retry" - ) from exc - await self._session.refresh(policy) - return SubmissionArtifactPolicyResponse.model_validate(policy), True - async def run_post_submit_checker_policy_derivation_agent( self, actor: ActorContext, @@ -2715,6 +2582,15 @@ async def _verified_guide_source_material( representative_task_material=RepresentativeTaskMaterialContext(items=[]), ) + async def verified_guide_source_material_for_agent( + self, + guide: ProjectGuide, + snapshot: GuideSourceSnapshot, + report: GuideSufficiencyReport, + ) -> GuideSourceMaterial: + """Load canonical ART-verified material for a setup agent.""" + return await self._verified_guide_source_material(guide, snapshot, report) + @staticmethod def _verified_agent_item(item: Any) -> GuideSourceItemMaterial: """Project one canonical extraction row into bounded untrusted agent input.""" @@ -3274,6 +3150,20 @@ async def _validate_agent_sufficiency_report_for_derivation( ) await self._verified_report_usages(sufficiency_report) + async def validate_agent_sufficiency_report_for_derivation( + self, sufficiency_report: GuideSufficiencyReport | None + ) -> None: + """Validate server-owned sufficiency provenance for downstream agents.""" + await self._validate_agent_sufficiency_report_for_derivation(sufficiency_report) + + def canonical_agent_submission_policy_body( + self, policy_body: dict[str, Any] + ) -> dict[str, Any]: + """Canonicalize agent output and enforce platform policy defaults.""" + canonical = self._canonical_policy_body(policy_body) + self._merge_effective_submission_artifact_policy(canonical) + return canonical + async def _verified_report_usages( self, sufficiency_report: GuideSufficiencyReport, diff --git a/backend/app/modules/projects/submission_policy_mutation_repository.py b/backend/app/modules/projects/submission_policy_mutation_repository.py index 536af4f5f..3bebf474a 100644 --- a/backend/app/modules/projects/submission_policy_mutation_repository.py +++ b/backend/app/modules/projects/submission_policy_mutation_repository.py @@ -104,6 +104,7 @@ async def reserve( setup_generation: int, setup_task_id: UUID | None, correlation_id: UUID | None, + status: Literal["reserved", "pending"] = "pending", ) -> tuple[ Literal["claimed", "mismatch", "pending", "replayed"], SubmissionPolicyMutationIdempotencyRecord, @@ -128,7 +129,7 @@ async def reserve( "setup_generation": setup_generation, "setup_task_id": setup_task_id, "correlation_id": correlation_id, - "status": "pending", + "status": status, } record_id = await self._session.scalar( insert(SubmissionPolicyMutationIdempotencyRecord) @@ -188,6 +189,39 @@ async def reserve( return "mismatch", record return ("replayed" if record.status == "committed" else "pending"), record + async def bind_reserved_execution( + self, + operation_id: UUID, + *, + expected_request_digest: str, + expected_resource_context_digest: str, + request_digest: str, + resource_context_digest: str, + resource_context_json: dict, + ) -> SubmissionPolicyMutationIdempotencyRecord | None: + """Advance one durable pre-I/O reservation to exact final replay custody.""" + record_id = await self._session.scalar( + update(SubmissionPolicyMutationIdempotencyRecord) + .where( + SubmissionPolicyMutationIdempotencyRecord.operation_id == operation_id, + SubmissionPolicyMutationIdempotencyRecord.status == "reserved", + SubmissionPolicyMutationIdempotencyRecord.request_digest + == expected_request_digest, + SubmissionPolicyMutationIdempotencyRecord.resource_context_digest + == expected_resource_context_digest, + ) + .values( + status="pending", + request_digest=request_digest, + resource_context_digest=resource_context_digest, + resource_context_json=resource_context_json, + ) + .returning(SubmissionPolicyMutationIdempotencyRecord.id) + ) + if record_id is None: + return None + return await self._session.get(SubmissionPolicyMutationIdempotencyRecord, record_id) + async def complete( self, operation_id: UUID, diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index 5ede129e5..b775515b4 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -3,15 +3,24 @@ from __future__ import annotations import json +from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Literal, cast from uuid import NAMESPACE_URL, UUID, uuid5 from pydantic import JsonValue +from sqlalchemy import text from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from app.core.hashing import canonical_json_hash +from app.core.project_agents import get_project_guide_agent_runtime +from app.interfaces.project_agents import ( + AgentFinding, + GuideSufficiencyAgentResult, + ProjectAgentRuntimeError, +) +from app.interfaces.artifact_operations import GuideSufficiencyMaterialPort from app.modules.actors.service import ResolvedActor from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.prepared import PreparedAuthorizationService @@ -22,6 +31,7 @@ PreparedAuthorizationUnsupported, PreparedAuthorityScope, PreparedAuthorityScopeKind, + ProjectSetupServiceCustodyContext, ProjectSubmissionArtifactPolicyMutationResourceContext, authorization_resource_digest, ) @@ -36,10 +46,18 @@ from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.schemas import ( SubmissionArtifactPolicyCreate, + SubmissionArtifactPolicyInput, SubmissionArtifactPolicyResponse, SubmissionArtifactPolicyUpdate, ) from app.modules.projects.service import ( + AGENT_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE, + PROJECT_GUIDE_SUFFICIENCY_AGENT_NAME, + PROJECT_GUIDE_SUFFICIENCY_AGENT_VERSION, + REPORT_STATUS_TO_AGENT_SUFFICIENCY_STATUS, + SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_NAME, + SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_VERSION, + AgentRuntimeUnavailable, GuideEditBlocked, GuideNotFound, MANUAL_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE, @@ -48,6 +66,7 @@ ProjectService, ProjectServiceError, SubmissionArtifactPolicyNotFound, + agent_submission_artifact_policy_version, ) from app.modules.projects.submission_policy_mutation_repository import ( SubmissionPolicyMutationReplayRepository, @@ -112,12 +131,47 @@ class _ManualPolicyLineage: class SubmissionPolicyMutationService: """Stage replay custody without owning commit, rollback, or product writes.""" - def __init__(self, session: AsyncSession) -> None: + def __init__( + self, + session: AsyncSession, + *, + material: GuideSufficiencyMaterialPort | None = None, + ) -> None: self._session = session self._replay = SubmissionPolicyMutationReplayRepository(session) self._projects = ProjectRepository(session) self._admin = AdminAuthorizationRepository(session) - self._validation = ProjectService(session) + self._validation = ProjectService(session, guide_sufficiency_material=material) + + @asynccontextmanager + async def _execution_fence(self, actor_profile_id: str, action: ActionId, key: UUID): + """Serialize one service-owned external derivation across processes.""" + engine = self._session.bind + if not isinstance(engine, AsyncEngine): + raise RuntimeError("submission-policy derivation requires an async database engine") + digest = canonical_json_hash( + { + "domain": "workstream.submission_policy.execution_fence.v1", + "actor_profile_id": actor_profile_id, + "action_id": action.value, + "key": str(key), + } + ) + lock_key = int(digest.removeprefix("sha256:")[:16], 16) + if lock_key >= 2**63: + lock_key -= 2**64 + async with engine.connect() as connection: + acquired = await connection.scalar( + text("select pg_try_advisory_lock(:lock_key)"), {"lock_key": lock_key} + ) + if acquired is not True: + raise SubmissionPolicyMutationConflict("idempotency_pending") + try: + yield + finally: + await connection.execute( + text("select pg_advisory_unlock(:lock_key)"), {"lock_key": lock_key} + ) @staticmethod def _stable_uuid(*parts: object) -> UUID: @@ -855,6 +909,490 @@ async def update_manual( ), ) + @staticmethod + def _prove_setup_service_authority(decision) -> None: + if ( + decision.matched_authority_kind is not MatchedAuthorityKind.FIXED_SERVICE + or decision.matched_grant_id is not None + ): + raise RuntimeError("policy derivation lacked fixed setup-service authority") + + @staticmethod + def _service_identity( + actor_profile_id: UUID, + identity_link_id: UUID, + custody, + ) -> tuple[UUID, UUID, UUID]: + operation_id = SubmissionPolicyMutationService._stable_uuid( + "derive-operation", actor_profile_id, identity_link_id, custody.setup_run_id, + custody.setup_generation, + ) + policy_id = SubmissionPolicyMutationService._stable_uuid("derive-policy", operation_id) + replay_key = SubmissionPolicyMutationService._stable_uuid("derive-replay", operation_id) + return operation_id, policy_id, replay_key + + @staticmethod + def _service_resource( + *, + project_id: UUID, + guide_id: UUID, + policy_id: UUID, + operation_id: UUID, + request_digest: str, + lineage: _ManualPolicyLineage, + custody, + ) -> ProjectSubmissionArtifactPolicyMutationResourceContext: + return ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=policy_id, + operation_id=operation_id, + request_digest=request_digest, + scope_project_id=project_id, + guide_id=guide_id, + guide_version=lineage.guide_version, + source_snapshot_id=lineage.snapshot_id, + source_snapshot_hash=lineage.snapshot_hash, + target_kind="derive", + execution_kind="setup_service", + policy_id=policy_id, + policy_version=agent_submission_artifact_policy_version(lineage.snapshot_hash), + policy_generation=lineage.setup_generation, + setup_generation=lineage.setup_generation, + sufficiency_report_id=lineage.report_id, + sufficiency_status=lineage.report_status, + sufficiency_acknowledgement_digest=lineage.acknowledgement_digest, + stale_output_digest=custody.stale_output_digest, + setup_service_custody=custody, + ) + + async def resolve_setup_service_custody( + self, + *, + project_id: UUID, + guide_id: UUID, + source_snapshot_id: UUID, + setup_run_id: UUID, + setup_generation: int, + task_id: UUID, + correlation_id: UUID, + ): + """Resolve the exact persisted derivation step to the closed AUTH selector.""" + lineage = await self._lineage(project_id, guide_id, source_snapshot_id, lock=False) + setup = await self._projects.lock_project_setup_run(str(setup_run_id)) + running = ( + setup is not None + and setup.status == "running_policy_derivation_agent" + and setup.current_step == "submission_artifact_policy_derivation" + and setup.output_submission_artifact_policy_id is None + ) + completed = ( + setup is not None + and setup.status == "policy_draft_ready" + and setup.current_step == "submission_artifact_policy_derivation" + and setup.output_submission_artifact_policy_id is not None + ) + if ( + lineage.setup_run_id != setup_run_id + or lineage.setup_generation != setup_generation + or setup is None + or not (running or completed) + or setup.celery_task_id != str(task_id) + or setup.output_sufficiency_report_id != str(lineage.report_id) + or correlation_id != uuid5(NAMESPACE_URL, f"{task_id}:correlation") + ): + raise SubmissionPolicyMutationConflict("project_setup_run_context_mismatch") + stale_output_digest = self._policy_derivation_stale_output_digest(setup) + + return ProjectSetupServiceCustodyContext( + setup_run_id=setup_run_id, + scope_project_id=project_id, + guide_id=guide_id, + source_snapshot_id=source_snapshot_id, + setup_generation=setup_generation, + expected_step="submission_artifact_policy", + task_id=task_id, + correlation_id=correlation_id, + stale_output_digest=stale_output_digest, + ) + + @staticmethod + def _policy_derivation_stale_output_digest(setup) -> str: + return canonical_json_hash( + { + "domain": "workstream.project_setup.policy_derivation_stale_output.v1", + "setup_run_id": setup.id, + "setup_generation": setup.setup_generation, + "current_step": setup.current_step, + "sufficiency_report_id": setup.output_sufficiency_report_id, + "submission_artifact_policy_id": None, + } + ) + + async def _lock_complete_derivation_lineage( + self, + project_id: UUID, + guide_id: UUID, + source_snapshot_id: UUID, + *, + expected_policy_id: UUID | None = None, + ) -> _ManualPolicyLineage: + """Lock the shared 12F3/12F4/12G policy chain in its total order.""" + lineage = await self._lineage( + project_id, guide_id, source_snapshot_id, lock=True + ) + policies = await self._projects.lock_submission_artifact_policies( + str(project_id), str(guide_id), lineage.guide_version + ) + current = [ + policy + for policy in policies + if policy.lifecycle_status in {"draft", "approved"} + ] + if current and not ( + expected_policy_id is not None + and len(current) == 1 + and current[0].id == str(expected_policy_id) + and current[0].derivation_source + == AGENT_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE + ): + raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") + effective = await self._projects.lock_effective_submission_artifact_policy( + str(project_id), lineage.guide_version, str(source_snapshot_id) + ) + if effective is not None: + await self._projects.lock_compiled_pre_submit_checker_policy(effective.id) + await self._projects.lock_post_submit_checker_policy_for_guide( + str(project_id), lineage.guide_version + ) + return lineage + + @staticmethod + def _require_exact_running_custody(setup, lineage, custody) -> None: + expected_stale = SubmissionPolicyMutationService._policy_derivation_stale_output_digest( + setup + ) + if ( + setup.id != str(custody.setup_run_id) + or setup.project_id != str(custody.scope_project_id) + or setup.guide_id != str(custody.guide_id) + or setup.source_snapshot_id != str(custody.source_snapshot_id) + or setup.setup_generation != custody.setup_generation + or setup.status != "running_policy_derivation_agent" + or setup.current_step != "submission_artifact_policy_derivation" + or setup.celery_task_id != str(custody.task_id) + or setup.output_sufficiency_report_id != str(lineage.report_id) + or setup.output_submission_artifact_policy_id is not None + or custody.correlation_id + != uuid5(NAMESPACE_URL, f"{custody.task_id}:correlation") + or custody.stale_output_digest != expected_stale + ): + raise SubmissionPolicyMutationConflict("project_setup_run_context_mismatch") + + @staticmethod + def _require_exact_completed_custody(setup, lineage, custody, policy_id: UUID) -> None: + if ( + setup.id != str(custody.setup_run_id) + or setup.project_id != str(custody.scope_project_id) + or setup.guide_id != str(custody.guide_id) + or setup.source_snapshot_id != str(custody.source_snapshot_id) + or setup.setup_generation != custody.setup_generation + or setup.status != "policy_draft_ready" + or setup.current_step != "submission_artifact_policy_derivation" + or setup.celery_task_id != str(custody.task_id) + or setup.output_sufficiency_report_id != str(lineage.report_id) + or setup.output_submission_artifact_policy_id != str(policy_id) + or custody.correlation_id + != uuid5(NAMESPACE_URL, f"{custody.task_id}:correlation") + ): + raise SubmissionPolicyMutationConflict("project_setup_run_context_mismatch") + + @asynccontextmanager + async def run_setup_service( + self, + *, + actor_profile_id: UUID, + identity_link_id: UUID, + prepared: PreparedAuthorizationService, + project_id: UUID, + guide_id: UUID, + source_snapshot_id: UUID, + custody, + ): + """Derive one policy under fresh, single-use fixed-service authority.""" + operation_id, policy_id, replay_key = self._service_identity( + actor_profile_id, identity_link_id, custody + ) + async with self._execution_fence( + str(actor_profile_id), ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, replay_key + ): + yield await self._run_setup_derivation( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + prepared=prepared, + project_id=project_id, + guide_id=guide_id, + source_snapshot_id=source_snapshot_id, + custody=custody, + operation_id=operation_id, + policy_id=policy_id, + ) + + async def _run_setup_derivation( + self, + *, + actor_profile_id: UUID, + identity_link_id: UUID, + prepared: PreparedAuthorizationService, + project_id: UUID, + guide_id: UUID, + source_snapshot_id: UUID, + custody, + operation_id: UUID, + policy_id: UUID, + ) -> SubmissionPolicyMutationOutcome: + action = ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE + initial = await self._lineage(project_id, guide_id, source_snapshot_id, lock=False) + preflight_digest = canonical_json_hash( + {"domain": "workstream.submission_policy.derive.preflight.v1", "custody": custody.model_dump(mode="json")} + ) + preflight_resource = self._service_resource( + project_id=project_id, guide_id=guide_id, policy_id=policy_id, + operation_id=operation_id, request_digest=preflight_digest, + lineage=initial, custody=custody, + ) + preflight_caller = PreparedAuthorizationInput( + idempotency_key=operation_id, + request_value=cast(JsonValue, preflight_resource.model_dump(mode="json")), + ) + handle = await self._prepare( + prepared, action, preflight_caller, project_id, preflight_resource + ) + decision = await prepared.consume(handle, action, preflight_caller, preflight_resource) + self._prove_setup_service_authority(decision) + await self._session.rollback() + + existing_replay = await self._replay.find_by_operation(operation_id) + if existing_replay is not None: + if ( + existing_replay.actor_profile_id != str(actor_profile_id) + or existing_replay.identity_link_id != str(identity_link_id) + or existing_replay.service_identity != ServiceIdentity.PROJECT_SETUP.value + or existing_replay.action_id != action.value + or existing_replay.setup_run_id != str(custody.setup_run_id) + or existing_replay.setup_generation != custody.setup_generation + or existing_replay.setup_task_id != custody.task_id + or existing_replay.correlation_id != custody.correlation_id + or existing_replay.status != "committed" + or existing_replay.response_json is None + or existing_replay.committed_policy_id != str(policy_id) + ): + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + replay_resource = ( + ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate_json( + json.dumps(existing_replay.resource_context_json) + ) + ) + final = await self._lock_complete_derivation_lineage( + project_id, + guide_id, + source_snapshot_id, + expected_policy_id=policy_id, + ) + if ( + final != initial + or existing_replay.resource_context_digest + != authorization_resource_digest(replay_resource) + or replay_resource + != self._service_resource( + project_id=project_id, + guide_id=guide_id, + policy_id=policy_id, + operation_id=operation_id, + request_digest=existing_replay.request_digest, + lineage=final, + custody=custody, + ) + ): + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + setup = await self._projects.lock_project_setup_run(str(custody.setup_run_id)) + if setup is None: + raise SubmissionPolicyMutationConflict("project_setup_run_context_mismatch") + self._require_exact_completed_custody(setup, final, custody, policy_id) + persisted = await self._projects.get_submission_artifact_policy(str(policy_id)) + persisted_response = ( + SubmissionArtifactPolicyResponse.model_validate(persisted) + if persisted is not None + else None + ) + replay_response = SubmissionArtifactPolicyResponse.model_validate( + existing_replay.response_json + ) + if persisted_response is None or replay_response != persisted_response: + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + replay_caller = PreparedAuthorizationInput( + idempotency_key=operation_id, + request_value=cast(JsonValue, replay_resource.model_dump(mode="json")), + ) + replay_handle = await self._prepare( + prepared, action, replay_caller, project_id, replay_resource + ) + replay_decision = await prepared.consume( + replay_handle, action, replay_caller, replay_resource + ) + self._prove_setup_service_authority(replay_decision) + return SubmissionPolicyMutationOutcome( + replay_response, + True, + ) + + existing_policy = ( + await self._projects.get_agent_derived_submission_artifact_policy_for_snapshot( + str(project_id), initial.guide_version, str(source_snapshot_id) + ) + ) + if existing_policy is not None: + raise SubmissionPolicyMutationConflict("submission_policy_replay_missing") + + preflight_facts = SubmissionPolicyReplayFacts( + actor_profile_id=str(actor_profile_id), + identity_link_id=str(identity_link_id), + service_identity=ServiceIdentity.PROJECT_SETUP.value, + action_id=action.value, + idempotency_key=None, + request_digest=preflight_digest, + resource_context=preflight_resource, + operation_id=operation_id, + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=str(source_snapshot_id), + policy_id=str(policy_id), + setup_run_id=str(custody.setup_run_id), + setup_generation=custody.setup_generation, + setup_task_id=custody.task_id, + correlation_id=custody.correlation_id, + ) + disposition, _ = await self.reserve_replay(preflight_facts, execution_claim=True) + if disposition != "claimed": + raise SubmissionPolicyMutationConflict(f"idempotency_{disposition}") + # Persist the execution claim before external material or agent I/O. A + # crashed delivery therefore remains pending and cannot repeat that I/O. + await self._session.commit() + + guide = await self._projects.get_guide(str(guide_id)) + snapshot = await self._projects.get_guide_source_snapshot(str(source_snapshot_id)) + report = await self._projects.get_guide_sufficiency_report(str(initial.report_id)) + if guide is None or snapshot is None or report is None: + raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") + await self._validation.validate_agent_sufficiency_report_for_derivation(report) + material = await self._validation.verified_guide_source_material_for_agent( + guide, snapshot, report + ) + runtime_report = GuideSufficiencyAgentResult( + status=REPORT_STATUS_TO_AGENT_SUFFICIENCY_STATUS[report.status], + findings=[AgentFinding.model_validate(finding) for finding in report.findings], + summary=report.summary, + agent_name=PROJECT_GUIDE_SUFFICIENCY_AGENT_NAME, + agent_version=PROJECT_GUIDE_SUFFICIENCY_AGENT_VERSION, + ) + try: + result = await get_project_guide_agent_runtime().derive_submission_artifact_policy( + material, runtime_report + ) + except ProjectAgentRuntimeError: + raise AgentRuntimeUnavailable( + "submission artifact policy agent is unavailable" + ) from None + try: + validated_policy = SubmissionArtifactPolicyInput.model_validate(result.policy_body) + except ValueError as exc: + raise PolicySetupBlocked("derived submission artifact policy is invalid") from exc + policy_body = self._validation.canonical_agent_submission_policy_body( + validated_policy.model_dump(mode="json") + ) + policy_hash = canonical_json_hash(policy_body) + final = await self._lock_complete_derivation_lineage( + project_id, guide_id, source_snapshot_id + ) + if final != initial: + raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") + setup = await self._projects.lock_project_setup_run(str(custody.setup_run_id)) + if setup is None: + raise SubmissionPolicyMutationConflict("project_setup_run_context_mismatch") + self._require_exact_running_custody(setup, final, custody) + request_digest = canonical_json_hash( + { + "domain": "workstream.submission_policy.derive.final.v1", + "custody": custody.model_dump(mode="json"), + "policy_hash": policy_hash, + "change_summary": result.change_summary, + } + ) + resource = self._service_resource( + project_id=project_id, guide_id=guide_id, policy_id=policy_id, + operation_id=operation_id, request_digest=request_digest, + lineage=final, custody=custody, + ) + caller = PreparedAuthorizationInput( + idempotency_key=operation_id, + request_value=cast(JsonValue, resource.model_dump(mode="json")), + ) + handle = await self._prepare(prepared, action, caller, project_id, resource) + decision = await prepared.consume(handle, action, caller, resource) + self._prove_setup_service_authority(decision) + preflight_values = self._replay_values(preflight_facts) + final_facts = SubmissionPolicyReplayFacts( + actor_profile_id=str(actor_profile_id), identity_link_id=str(identity_link_id), + service_identity=ServiceIdentity.PROJECT_SETUP.value, action_id=action.value, + idempotency_key=None, request_digest=request_digest, resource_context=resource, + operation_id=operation_id, project_id=str(project_id), guide_id=str(guide_id), + source_snapshot_id=str(source_snapshot_id), policy_id=str(policy_id), + setup_run_id=str(custody.setup_run_id), setup_generation=custody.setup_generation, + setup_task_id=custody.task_id, correlation_id=custody.correlation_id, + ) + final_values = self._replay_values(final_facts) + rebound = await self._replay.bind_reserved_execution( + operation_id, + expected_request_digest=preflight_digest, + expected_resource_context_digest=str( + preflight_values["resource_context_digest"] + ), + request_digest=request_digest, + resource_context_digest=str(final_values["resource_context_digest"]), + resource_context_json=cast(dict, final_values["resource_context_json"]), + ) + if rebound is None: + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + policy = SubmissionArtifactPolicy( + id=str(policy_id), project_id=str(project_id), guide_id=str(guide_id), + guide_version=final.guide_version, source_snapshot_id=str(source_snapshot_id), + source_snapshot_hash=final.snapshot_hash, + policy_version=agent_submission_artifact_policy_version(final.snapshot_hash), + lifecycle_status="draft", policy_body=policy_body, policy_hash=policy_hash, + derivation_source=AGENT_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE, + source_material_refs=list(final.source_material_refs), + derivation_agent_name=SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_NAME, + derivation_agent_version=SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_VERSION, + created_by=str(actor_profile_id), created_by_actor_profile_id=str(actor_profile_id), + created_via_identity_link_id=str(identity_link_id), + created_by_admin_role_grant_id=None, + created_by_service_identity=ServiceIdentity.PROJECT_SETUP.value, + creation_scope_type="service", creation_scope_project_id=str(project_id), + creation_action_id=action.value, + creation_decision_event_id=str(decision.decision_id), + change_summary=result.change_summary, + ) + await self._projects.add_submission_artifact_policy(policy) + setup.status = "policy_draft_ready" + setup.current_step = "submission_artifact_policy_derivation" + setup.output_submission_artifact_policy_id = policy.id + response = SubmissionArtifactPolicyResponse.model_validate(policy) + await self.complete_replay( + final_facts, + response_json=response.model_dump(mode="json"), + committed_policy_id=policy.id, + ) + return SubmissionPolicyMutationOutcome(response, False) + def _require_root_transaction(self) -> None: transaction = self._session.sync_session.get_transaction() if ( @@ -925,14 +1463,18 @@ def _replay_values(facts: SubmissionPolicyReplayFacts) -> dict[str, object]: } async def reserve_replay( - self, facts: SubmissionPolicyReplayFacts + self, + facts: SubmissionPolicyReplayFacts, + *, + execution_claim: bool = False, ) -> tuple[ Literal["claimed", "mismatch", "pending", "replayed"], SubmissionPolicyMutationIdempotencyRecord, ]: """Reserve replay custody while leaving transaction ownership to the caller.""" self._require_root_transaction() - return await self._replay.reserve(**self._replay_values(facts)) + status: Literal["reserved", "pending"] = "reserved" if execution_claim else "pending" + return await self._replay.reserve(**self._replay_values(facts), status=status) async def complete_replay( self, diff --git a/backend/app/modules/projects/sufficiency_mutation_service.py b/backend/app/modules/projects/sufficiency_mutation_service.py index a0442689f..3b2f1b36a 100644 --- a/backend/app/modules/projects/sufficiency_mutation_service.py +++ b/backend/app/modules/projects/sufficiency_mutation_service.py @@ -1480,6 +1480,22 @@ async def acknowledge_warnings( report.warning_acknowledgement_scope_project_id = str(project_id) report.warning_acknowledgement_action_id = action.value report.warning_acknowledgement_decision_event_id = str(decision.decision_id) + if report.project_setup_run_id is not None: + setup_run = await self._projects.lock_project_setup_run(report.project_setup_run_id) + if ( + setup_run is None + or setup_run.id != str(final.setup_run_id) + or setup_run.setup_generation != final.setup_generation + or setup_run.output_sufficiency_report_id != report.id + or setup_run.output_submission_artifact_policy_id is not None + ): + raise GuideSufficiencyMutationConflict("project_setup_run_context_mismatch") + # A warning stopped the automatic continuation before derivation I/O. + setup_run.status = "enqueue_failed" + setup_run.current_step = "enqueue" + setup_run.celery_task_id = None + setup_run.error_code = None + setup_run.error_summary = None response = GuideSufficiencyReportResponse.model_validate(report) await self._replay.complete( replay, response_json=response.model_dump(mode="json"), report_id=report.id diff --git a/backend/app/workers/project_setup.py b/backend/app/workers/project_setup.py index 0c1db7053..e043fe9d7 100644 --- a/backend/app/workers/project_setup.py +++ b/backend/app/workers/project_setup.py @@ -24,6 +24,9 @@ from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.prepared import fixed_service_prepared_authorization from app.modules.projects.sufficiency_mutation_service import GuideSufficiencyMutationService +from app.modules.projects.submission_policy_mutation_service import ( + SubmissionPolicyMutationService, +) from app.modules.projects.setup_queue import pre_submit_setup_task_id from app.schemas.auth import ActorContext from app.workers.async_runner import run_async_task @@ -84,6 +87,52 @@ async def _run_authorized_setup_sufficiency( return outcome +async def _run_authorized_setup_policy_derivation( + session, + *, + project_id: str, + guide_id: str, + source_snapshot_id: str, + setup_run_id: str, + setup_generation: int, +): + """Compose fresh fixed-service authority for one policy derivation.""" + mutation = SubmissionPolicyMutationService( + session, + material=SqlAlchemyGuideSufficiencyMaterialAdapter(session), + ) + execution_name = pre_submit_setup_task_id(setup_run_id, setup_generation) + task_id = UUID(execution_name) + correlation_id = uuid5(NAMESPACE_URL, f"{execution_name}:correlation") + custody = await mutation.resolve_setup_service_custody( + project_id=UUID(project_id), + guide_id=UUID(guide_id), + source_snapshot_id=UUID(source_snapshot_id), + setup_run_id=UUID(setup_run_id), + setup_generation=setup_generation, + task_id=task_id, + correlation_id=correlation_id, + ) + async with fixed_service_prepared_authorization( + session, + service_identity=ServiceIdentity.PROJECT_SETUP, + request_id=task_id, + correlation_id=correlation_id, + ) as authority: + execution = mutation.run_setup_service( + actor_profile_id=authority.actor_profile_id, + identity_link_id=authority.identity_link_id, + prepared=authority.service, + project_id=UUID(project_id), + guide_id=UUID(guide_id), + source_snapshot_id=UUID(source_snapshot_id), + custody=custody, + ) + async with execution as outcome: + await (session.rollback() if outcome.replayed else session.commit()) + return outcome + + def project_setup_pipeline_actor() -> ActorContext: """Return the internal actor used for server-owned setup automation.""" return ActorContext( @@ -189,7 +238,6 @@ async def _run_verified_pre_submit_sufficiency_continuation( setup_generation: int, ) -> dict[str, Any]: """Run the live ART-backed same-generation sufficiency continuation.""" - actor = project_setup_pipeline_actor() engine = create_async_engine(get_database_url(), pool_pre_ping=True) session_factory = async_sessionmaker(engine, expire_on_commit=False) try: @@ -260,23 +308,18 @@ async def _run_verified_pre_submit_sufficiency_continuation( current_step="submission_artifact_policy_derivation", output_sufficiency_report_id=sufficiency_report.id, ) - policy, _ = await service.run_submission_artifact_policy_derivation_agent( - actor, - project_id, - guide_id, - source_snapshot_id, - ) - await service.update_project_setup_run_status( - setup_run_id, - status="policy_draft_ready", - current_step="submission_artifact_policy_derivation", - output_sufficiency_report_id=sufficiency_report.id, - output_submission_artifact_policy_id=policy.id, + policy_outcome = await _run_authorized_setup_policy_derivation( + session, + project_id=project_id, + guide_id=guide_id, + source_snapshot_id=source_snapshot_id, + setup_run_id=setup_run_id, + setup_generation=setup_generation, ) return { "status": "policy_draft_ready", "guide_sufficiency_report_id": sufficiency_report.id, - "submission_artifact_policy_id": policy.id, + "submission_artifact_policy_id": policy_outcome.response.id, } except GuideSufficiencyMaterialUnavailable as exc: await session.rollback() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7d50c6cc8..5f7d45199 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "3b7e3ebf76d36ebb59e4a4eea6d798aef292b224f306a82b70c400afc395fc48" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "1d427ad3949f452b22c978dc757b6029bb98e524ab4459791cd2a913f4fa5e11" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index ba2f85672..77178f7f8 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -73,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0058_pre_submit_evidence" +HEAD_REVISION = "0059_policy_execution_claim" pytestmark = pytest.mark.postgres_schema_contract @@ -370,7 +370,7 @@ def test_submission_policy_authority_safe_empty_roundtrip( assert upgraded["provenance_triggers"] == 4 assert dict(upgraded["action_states"]) == { ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE.value: "active", - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE.value: "planned", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE.value: "active", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE.value: "active", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE.value: "planned", } diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index e400c7cd9..dfd2efc58 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2066,9 +2066,9 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> "project.effective_policy.manage", "WS-AUTH-001-12F2", ), - "project.submission_artifact_policy.derive": ( - "project.effective_policy.manage", - "WS-AUTH-001-12F", + "project.submission_artifact_policy.derive": ( + "project.effective_policy.manage", + "WS-AUTH-001-12F3", ), "project.submission_artifact_policy.update": ( "project.effective_policy.manage", @@ -2136,6 +2136,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, @@ -2236,14 +2237,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 51 + == 52 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 49 + == 48 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2768,8 +2769,8 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> ), ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F, - ActionAvailability.PLANNED, + ActionOwner.AUTH_12F3, + ActionAvailability.ACTIVE, ), ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, @@ -2784,8 +2785,8 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> } -def test_submission_artifact_policy_create_update_activation_is_12f2_only() -> None: - """Activate only the two human manual-draft actions under the child owner.""" +def test_submission_artifact_policy_draft_actions_have_exact_child_owners() -> None: + """Activate human drafting and fixed-service derivation under exact owners.""" for action in ( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, @@ -2794,12 +2795,12 @@ def test_submission_artifact_policy_create_update_activation_is_12f2_only() -> N assert definition.owner is ActionOwner.AUTH_12F2 assert definition.availability is ActionAvailability.ACTIVE assert definition.permission_id is PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE - for action in ( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, - ): - assert ACTION_BY_ID[action].owner is ActionOwner.AUTH_12F - assert ACTION_BY_ID[action].availability is ActionAvailability.PLANNED + derive = ACTION_BY_ID[ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE] + assert derive.owner is ActionOwner.AUTH_12F3 + assert derive.availability is ActionAvailability.ACTIVE + approval = ACTION_BY_ID[ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE] + assert approval.owner is ActionOwner.AUTH_12F + assert approval.availability is ActionAvailability.PLANNED active_internal = { ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -2808,6 +2809,7 @@ def test_submission_artifact_policy_create_update_activation_is_12f2_only() -> N ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, } assert { action @@ -3762,6 +3764,7 @@ def __getattr__(self, name: str): caller_input, scope = _submission_policy_derive_prepare_inputs() elif action_id in { ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, }: @@ -5980,6 +5983,76 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): ) assert decision.allowed is True assert decision.matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE + + +@pytest.mark.asyncio +async def test_submission_artifact_policy_derive_prepared_service_is_exact_and_single_use() -> None: + """Only the fixed setup service receives exact derivation authority.""" + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + service_identity=ServiceIdentity.PROJECT_SETUP, + ) + assert isinstance(context, ServiceAuthorizationContext) + session = _PreparedTestSession() + + class LockedServiceFacts: + async def lock_request_actor(self, identity_link_id, actor_profile_id): + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status="active", + ), + SimpleNamespace( + id=str(actor_profile_id), + actor_kind="service", + status="active", + service_identity=ServiceIdentity.PROJECT_SETUP.value, + ), + ) + + facts = LockedServiceFacts() + authorization, evidence = _runtime_service( + context, session=session, admin_repository=facts + ) + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + facts, + ) + caller, scope = _submission_policy_derive_prepare_inputs() + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate_json( + json.dumps(caller.request_value) + ) + handle = await prepared.prepare( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, caller, scope + ) + wrong = resource.model_copy(update={"source_snapshot_hash": "sha256:" + "b" * 64}) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + caller, + wrong, + ) + decision = await prepared.consume( + handle, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + caller, + resource, + ) + assert decision.allowed is True + assert decision.matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE + assert decision.matched_grant_id is None + assert len(evidence.events) == 1 + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + caller, + resource, + ) assert decision.matched_grant_id is None assert decision.matched_scope_project_id is None assert len(evidence.events) == 1 diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index f25aacb7d..50d1a861d 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -14,7 +14,7 @@ from decimal import Decimal from types import SimpleNamespace from typing import Any, cast -from uuid import UUID, uuid4 +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 import pytest # type: ignore[import-not-found] from pydantic import ValidationError @@ -111,8 +111,10 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.catalogue import ActionId from app.modules.projects import repository as project_repository_module -from app.modules.projects import router as project_router_module from app.modules.projects import service as project_service_module +from app.modules.projects import ( + submission_policy_mutation_service as submission_policy_mutation_service_module, +) from app.modules.projects import sufficiency_mutation_service as sufficiency_mutation_service_module from app.modules.projects import guide_mutation_router as guide_mutation_router_module from app.modules.projects import guide_mutation_service as guide_mutation_service_module @@ -1020,167 +1022,9 @@ def isolated_project_settings_cache() -> Iterator[None]: get_settings.cache_clear() -@pytest.mark.asyncio -async def test_submission_policy_derivation_persists_verified_agent_result( - monkeypatch: pytest.MonkeyPatch, -) -> None: - project_id, guide_id, snapshot_id = (str(uuid4()) for _ in range(3)) - guide = SimpleNamespace(id=guide_id, project_id=project_id, version="v1", status="draft") - snapshot = SimpleNamespace(id=snapshot_id, bundle_hash=f"sha256:{'a' * 64}") - report = SimpleNamespace( - status="passed", - findings=[], - summary="Verified guide is sufficient.", - ) - stored: list[SubmissionArtifactPolicy] = [] - - class Repository: - async def get_sufficiency_report_for_snapshot(self, _snapshot_id: str) -> Any: - return report - - async def get_agent_derived_submission_artifact_policy_for_snapshot( - self, *_args: Any - ) -> None: - return None - - async def add_submission_artifact_policy( - self, policy: SubmissionArtifactPolicy - ) -> SubmissionArtifactPolicy: - stored.append(policy) - return policy - - class Runtime: - async def derive_submission_artifact_policy( - self, material: Any, runtime_report: GuideSufficiencyAgentResult - ) -> SubmissionArtifactPolicyDerivationResult: - assert material.snapshot_id == snapshot_id - assert runtime_report.status == "guide_sufficient" - return SubmissionArtifactPolicyDerivationResult( - policy_version="ignored-server-derived", - policy_body=SubmissionArtifactPolicyInput().model_dump(mode="json"), - change_summary="Derived from the verified guide.", - agent_version="test-runtime-v1", - ) - - session = _RecordingSession() - service = ProjectService(cast(Any, session), agent_runtime=cast(Any, Runtime())) - service._repo = cast(Any, Repository()) - - async def get_guide(*_args: Any) -> Any: - return guide - - async def get_snapshot(*_args: Any) -> Any: - return snapshot - - async def no_op(*_args: Any, **_kwargs: Any) -> None: - return None - - async def material(*_args: Any) -> Any: - return SimpleNamespace(snapshot_id=snapshot_id) - - async def source_refs(*_args: Any) -> list[str]: - return [f"snapshot:{snapshot_id}"] - - service._get_project_guide = get_guide - service._lock_project_guide_for_setup = get_guide - service._get_snapshot_for_guide = get_snapshot - service._ensure_snapshot_is_latest = no_op - service.validate_source_snapshot_integrity = no_op - service._validate_sufficiency_report_allows_policy_derivation = cast(Any, lambda *_: None) - service._validate_agent_sufficiency_report_for_derivation = no_op - service._verified_guide_source_material = material - service._verified_source_material_refs = source_refs - service._merge_effective_submission_artifact_policy = cast(Any, lambda body: body) - monkeypatch.setattr( - project_service_module, - "SubmissionArtifactPolicyResponse", - _IdentityResponse, - ) - - policy, created = await service.run_submission_artifact_policy_derivation_agent( - _project_manager_actor(), project_id, guide_id, snapshot_id - ) - - assert created is True - assert policy is stored[0] - assert policy.derivation_source == "agent_derivation" - assert policy.source_material_refs == [f"snapshot:{snapshot_id}"] - assert policy.change_summary == "Derived from the verified guide." - assert session.rollbacks == 1 - assert session.commits == 1 - assert session.refreshed == [policy] - - -@pytest.mark.asyncio -async def test_submission_policy_derivation_returns_concurrent_winner( - monkeypatch: pytest.MonkeyPatch, -) -> None: - project_id, guide_id, snapshot_id = (str(uuid4()) for _ in range(3)) - guide = SimpleNamespace(id=guide_id, project_id=project_id, version="v1", status="draft") - snapshot = SimpleNamespace(id=snapshot_id, bundle_hash=f"sha256:{'a' * 64}") - report = SimpleNamespace(status="passed", findings=[], summary="Sufficient") - winner = SimpleNamespace(id="winner") - reads = 0 - - class Repository: - async def get_sufficiency_report_for_snapshot(self, _snapshot_id: str) -> Any: - return report - - async def get_agent_derived_submission_artifact_policy_for_snapshot( - self, *_args: Any - ) -> Any: - nonlocal reads - reads += 1 - return winner if reads == 3 else None - - async def add_submission_artifact_policy(self, _policy: Any) -> Any: - raise IntegrityError("insert", {}, RuntimeError("race")) - - class Runtime: - async def derive_submission_artifact_policy( - self, *_args: Any - ) -> SubmissionArtifactPolicyDerivationResult: - return SubmissionArtifactPolicyDerivationResult( - policy_version="agent-v1", - policy_body=SubmissionArtifactPolicyInput().model_dump(mode="json"), - change_summary="derived", - agent_version="test-runtime-v1", - ) - - session = _RecordingSession() - service = ProjectService(cast(Any, session), agent_runtime=cast(Any, Runtime())) - service._repo = cast(Any, Repository()) - - async def get_guide(*_args: Any) -> Any: - return guide - - async def get_snapshot(*_args: Any) -> Any: - return snapshot - - async def no_op(*_args: Any, **_kwargs: Any) -> None: - return None - - service._get_project_guide = get_guide - service._lock_project_guide_for_setup = get_guide - service._get_snapshot_for_guide = get_snapshot - service._ensure_snapshot_is_latest = no_op - service.validate_source_snapshot_integrity = no_op - service._validate_sufficiency_report_allows_policy_derivation = cast(Any, lambda *_: None) - service._validate_agent_sufficiency_report_for_derivation = no_op - service._validate_agent_derived_submission_artifact_policy = cast(Any, lambda *_: None) - service._verified_guide_source_material = cast(Any, lambda *_: no_op()) - service._verified_source_material_refs = cast(Any, lambda *_: no_op()) - service._merge_effective_submission_artifact_policy = cast(Any, lambda body: body) - monkeypatch.setattr( - project_service_module, "SubmissionArtifactPolicyResponse", _IdentityResponse - ) - - policy, created = await service.run_submission_artifact_policy_derivation_agent( - _project_manager_actor(), project_id, guide_id, snapshot_id - ) - - assert (policy, created) == (winner, False) - assert session.rollbacks == 2 +def test_submission_policy_derivation_has_no_public_project_service_seam() -> None: + """12F3 removes role-bearing inline derivation from import reachability.""" + assert not hasattr(ProjectService, "run_submission_artifact_policy_derivation_agent") @pytest.mark.asyncio @@ -2855,24 +2699,13 @@ def test_setup_mutations_use_locked_guide_helper() -> None: "request_post_submit_checker_policy_correction", "activate_guide", ] - agent_methods = [ - "run_submission_artifact_policy_derivation_agent", - ] - for method_name in locked_methods: source = inspect.getsource(getattr(ProjectService, method_name)) assert "_lock_project_guide_for_setup" in source assert "_get_project_guide(project_id, guide_id)" not in source - for method_name in agent_methods: - source = inspect.getsource(getattr(ProjectService, method_name)) - - assert "_get_project_guide(project_id, guide_id)" in source - assert "_lock_project_guide_for_setup" in source - assert source.index("_get_project_guide(project_id, guide_id)") < source.index( - "_lock_project_guide_for_setup" - ) + assert not hasattr(ProjectService, "run_submission_artifact_policy_derivation_agent") def test_policy_models_have_project_guide_foreign_keys() -> None: @@ -4062,11 +3895,6 @@ async def load(self, _request: object) -> GuideSufficiencyMaterialResult: assert resolved_material is not None return resolved_material - monkeypatch.setattr( - project_router_module, - "SqlAlchemyGuideSufficiencyMaterialAdapter", - VerifiedMaterialAdapter, - ) async with db_session.get_session_factory()() as session: guide = await session.get(ProjectGuide, guide_id) assert guide is not None @@ -7247,6 +7075,377 @@ async def fixed_authority(session: object, **facts: object): } +async def test_submission_artifact_policy_agent_worker_composes_fresh_service_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Policy derivation obtains fresh process-local PREP and transports IDs only.""" + monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") + get_settings.cache_clear() + from app.workers import project_setup as worker + + project_id, guide_id, snapshot_id, setup_run_id = (uuid4() for _ in range(4)) + calls: dict[str, object] = {} + + class Session: + commits = 0 + rollbacks = 0 + + async def commit(self) -> None: + self.commits += 1 + + async def rollback(self) -> None: + self.rollbacks += 1 + + class Mutation: + def __init__(self, session: object, *, material: object) -> None: + calls["session"] = session + calls["material"] = material + + async def resolve_setup_service_custody(self, **facts: object) -> object: + calls["custody_facts"] = facts + return "policy-custody" + + @asynccontextmanager + async def run_setup_service(self, **facts: object): + calls["run_facts"] = facts + yield SimpleNamespace(replayed=False, response=SimpleNamespace(id="policy")) + + @asynccontextmanager + async def fixed_authority(session: object, **facts: object): + calls["authority_session"] = session + calls["authority_facts"] = facts + yield SimpleNamespace( + actor_profile_id="setup-profile", + identity_link_id="setup-link", + service="prepared-service", + ) + + monkeypatch.setattr(worker, "SubmissionPolicyMutationService", Mutation) + monkeypatch.setattr(worker, "SqlAlchemyGuideSufficiencyMaterialAdapter", lambda _: "material") + monkeypatch.setattr(worker, "fixed_service_prepared_authorization", fixed_authority) + session = Session() + + outcome = await worker._run_authorized_setup_policy_derivation( + session, + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=str(snapshot_id), + setup_run_id=str(setup_run_id), + setup_generation=3, + ) + + assert outcome.response.id == "policy" + assert session.commits == 1 + assert session.rollbacks == 0 + assert calls["authority_facts"]["service_identity"] is ServiceIdentity.PROJECT_SETUP + custody = calls["custody_facts"] + assert custody["project_id"] == project_id + assert custody["setup_run_id"] == setup_run_id + assert custody["setup_generation"] == 3 + assert custody["task_id"] == calls["authority_facts"]["request_id"] + assert custody["correlation_id"] == calls["authority_facts"]["correlation_id"] + assert calls["run_facts"]["prepared"] == "prepared-service" + assert calls["run_facts"]["custody"] == "policy-custody" + + +async def test_submission_artifact_policy_agent_service_commits_and_replays_without_io( + project_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A committed worker redelivery returns the canonical draft with zero external I/O.""" + from app.workers import project_setup as worker + + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + adapter = await prepare_verified_sufficiency_route( + monkeypatch, project_id=project["id"], guide_id=guide["id"], snapshot=snapshot + ) + runtime = DeterministicTestProjectGuideAgentRuntime() + monkeypatch.setattr( + sufficiency_mutation_service_module, + "get_project_guide_agent_runtime", + lambda: runtime, + ) + report = await run_verified_sufficiency_as_setup_service( + monkeypatch, + project_id=project["id"], + guide_id=guide["id"], + snapshot_id=snapshot["id"], + material_adapter=adapter, + ) + async with db_session.get_session_factory()() as session: + setup = await session.scalar( + select(ProjectSetupRun).where(ProjectSetupRun.source_snapshot_id == snapshot["id"]) + ) + assert setup is not None + setup.status = "running_policy_derivation_agent" + setup.current_step = "submission_artifact_policy_derivation" + setup.output_sufficiency_report_id = report.id + await session.commit() + setup_run_id, setup_generation = setup.id, setup.setup_generation + + monkeypatch.setattr( + submission_policy_mutation_service_module, + "get_project_guide_agent_runtime", + lambda: runtime, + ) + monkeypatch.setattr(worker, "SqlAlchemyGuideSufficiencyMaterialAdapter", adapter) + async with db_session.get_session_factory()() as session: + first = await worker._run_authorized_setup_policy_derivation( + session, + project_id=project["id"], + guide_id=guide["id"], + source_snapshot_id=snapshot["id"], + setup_run_id=setup_run_id, + setup_generation=setup_generation, + ) + + assert first.response.derivation_source == "agent_derivation" + assert first.response.policy_version.startswith("agent-") + assert first.response.source_material_refs + assert first.response.policy_body["manifest_required"] is True + assert first.response.policy_body["artifact_hash_required"] is True + async with db_session.get_session_factory()() as session: + persisted = await session.get(SubmissionArtifactPolicy, first.response.id) + assert persisted is not None + assert persisted.created_by_service_identity == ServiceIdentity.PROJECT_SETUP.value + assert persisted.created_by_admin_role_grant_id is None + assert persisted.creation_decision_event_id is not None + update = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" + f"{first.response.id}", + headers=auth_headers(), + json={ + "expected_policy_hash": first.response.policy_hash, + "successor_policy_version": "manual-v2", + "change_summary": "Human rewrite of service output.", + }, + ) + assert update.status_code == 409 + assert "agent-derived policy summaries are immutable" in update.json()["detail"] + + class NoReplayMaterial: + def __init__(self, _session: object) -> None: + pass + + async def load(self, _request: object) -> object: + raise AssertionError("committed replay performed material I/O") + + class NoReplayRuntime: + async def derive_submission_artifact_policy(self, *_: object) -> object: + raise AssertionError("committed replay invoked the agent") + + monkeypatch.setattr(worker, "SqlAlchemyGuideSufficiencyMaterialAdapter", NoReplayMaterial) + monkeypatch.setattr( + submission_policy_mutation_service_module, + "get_project_guide_agent_runtime", + lambda: NoReplayRuntime(), + ) + async with db_session.get_session_factory()() as session: + second = await worker._run_authorized_setup_policy_derivation( + session, + project_id=project["id"], + guide_id=guide["id"], + source_snapshot_id=snapshot["id"], + setup_run_id=setup_run_id, + setup_generation=setup_generation, + ) + + assert first.replayed is False + assert second.replayed is True + assert second.response.id == first.response.id + + # A crash-equivalent failure after external I/O leaves a durable execution + # reservation. Redelivery is denied before repeating material or agent I/O. + project_2 = await create_project(project_client) + guide_2 = await create_guide( + project_client, project_2["id"], complete_guide_payload() + ) + snapshot_2 = await create_source_snapshot( + project_client, project_2["id"], guide_2["id"] + ) + adapter_2 = await prepare_verified_sufficiency_route( + monkeypatch, + project_id=project_2["id"], + guide_id=guide_2["id"], + snapshot=snapshot_2, + ) + monkeypatch.setattr( + sufficiency_mutation_service_module, + "get_project_guide_agent_runtime", + lambda: runtime, + ) + report_2 = await run_verified_sufficiency_as_setup_service( + monkeypatch, + project_id=project_2["id"], + guide_id=guide_2["id"], + snapshot_id=snapshot_2["id"], + material_adapter=adapter_2, + ) + async with db_session.get_session_factory()() as session: + setup_2 = await session.scalar( + select(ProjectSetupRun).where( + ProjectSetupRun.source_snapshot_id == snapshot_2["id"] + ) + ) + assert setup_2 is not None + setup_2.status = "running_policy_derivation_agent" + setup_2.current_step = "submission_artifact_policy_derivation" + setup_2.output_sufficiency_report_id = report_2.id + await session.commit() + setup_run_id_2 = setup_2.id + setup_generation_2 = setup_2.setup_generation + + class FailedPolicyRuntime: + async def derive_submission_artifact_policy(self, *_: object) -> object: + raise ProjectAgentRuntimeError("provider stopped after admission") + + monkeypatch.setattr(worker, "SqlAlchemyGuideSufficiencyMaterialAdapter", adapter_2) + monkeypatch.setattr( + submission_policy_mutation_service_module, + "get_project_guide_agent_runtime", + lambda: FailedPolicyRuntime(), + ) + async with db_session.get_session_factory()() as session: + with pytest.raises(AgentRuntimeUnavailable): + await worker._run_authorized_setup_policy_derivation( + session, + project_id=project_2["id"], + guide_id=guide_2["id"], + source_snapshot_id=snapshot_2["id"], + setup_run_id=setup_run_id_2, + setup_generation=setup_generation_2, + ) + async with db_session.get_session_factory()() as session: + reservation = await session.scalar( + select(SubmissionPolicyMutationIdempotencyRecord).where( + SubmissionPolicyMutationIdempotencyRecord.project_id == project_2["id"] + ) + ) + assert reservation is not None + assert reservation.status == "reserved" + + monkeypatch.setattr(worker, "SqlAlchemyGuideSufficiencyMaterialAdapter", NoReplayMaterial) + monkeypatch.setattr( + submission_policy_mutation_service_module, + "get_project_guide_agent_runtime", + lambda: NoReplayRuntime(), + ) + async with db_session.get_session_factory()() as session: + with pytest.raises( + submission_policy_mutation_service_module.SubmissionPolicyMutationConflict, + match="idempotency_mismatch", + ): + await worker._run_authorized_setup_policy_derivation( + session, + project_id=project_2["id"], + guide_id=guide_2["id"], + source_snapshot_id=snapshot_2["id"], + setup_run_id=setup_run_id_2, + setup_generation=setup_generation_2, + ) + + +@pytest.mark.parametrize( + ("changed_field", "changed_value"), + [ + ("setup_generation", 2), + ("celery_task_id", str(uuid4())), + ("output_sufficiency_report_id", str(uuid4())), + ("current_step", "guide_sufficiency"), + ("status", "setup_blocked"), + ("output_submission_artifact_policy_id", str(uuid4())), + ], +) +def test_submission_artifact_policy_agent_final_custody_rejects_stale_output( + changed_field: str, + changed_value: object, +) -> None: + """Every post-agent setup-custody mismatch fails before persistence.""" + from app.modules.authorization.runtime import ProjectSetupServiceCustodyContext + + project_id, guide_id, snapshot_id, setup_run_id, report_id = (uuid4() for _ in range(5)) + task_id = uuid4() + correlation_id = uuid5(NAMESPACE_URL, f"{task_id}:correlation") + setup = SimpleNamespace( + id=str(setup_run_id), + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=str(snapshot_id), + setup_generation=1, + status="running_policy_derivation_agent", + current_step="submission_artifact_policy_derivation", + celery_task_id=str(task_id), + output_sufficiency_report_id=str(report_id), + output_submission_artifact_policy_id=None, + ) + stale_digest = canonical_json_hash( + { + "domain": "workstream.project_setup.policy_derivation_stale_output.v1", + "setup_run_id": setup.id, + "setup_generation": 1, + "current_step": "submission_artifact_policy_derivation", + "sufficiency_report_id": str(report_id), + "submission_artifact_policy_id": None, + } + ) + custody = ProjectSetupServiceCustodyContext( + setup_run_id=setup_run_id, + scope_project_id=project_id, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_generation=1, + expected_step="submission_artifact_policy", + task_id=task_id, + correlation_id=correlation_id, + stale_output_digest=stale_digest, + ) + setattr(setup, changed_field, changed_value) + lineage = SimpleNamespace(report_id=report_id) + + with pytest.raises(SubmissionPolicyMutationConflict): + SubmissionPolicyMutationService._require_exact_running_custody( + setup, lineage, custody + ) + + +def test_submission_artifact_policy_agent_replay_rejects_changed_completed_output() -> None: + """Completed replay is bound to the exact deterministic policy output.""" + from app.modules.authorization.runtime import ProjectSetupServiceCustodyContext + + project_id, guide_id, snapshot_id, setup_run_id, report_id = (uuid4() for _ in range(5)) + task_id, policy_id = uuid4(), uuid4() + custody = ProjectSetupServiceCustodyContext( + setup_run_id=setup_run_id, + scope_project_id=project_id, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_generation=1, + expected_step="submission_artifact_policy", + task_id=task_id, + correlation_id=uuid5(NAMESPACE_URL, f"{task_id}:correlation"), + stale_output_digest="sha256:" + "a" * 64, + ) + setup = SimpleNamespace( + id=str(setup_run_id), + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=str(snapshot_id), + setup_generation=1, + status="policy_draft_ready", + current_step="submission_artifact_policy_derivation", + celery_task_id=str(task_id), + output_sufficiency_report_id=str(report_id), + output_submission_artifact_policy_id=str(uuid4()), + ) + + with pytest.raises(SubmissionPolicyMutationConflict): + SubmissionPolicyMutationService._require_exact_completed_custody( + setup, SimpleNamespace(report_id=report_id), custody, policy_id + ) + + async def test_setup_service_recovers_exact_committed_sufficiency_replay( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -11794,7 +11993,7 @@ async def test_sufficiency_agent_blocks_thin_guides( assert response.findings[0]["code"] == "project_owner_clarification_required" -async def test_derivation_agent_allows_warning_report_without_acknowledgement_and_is_idempotent( +async def test_submission_artifact_policy_public_agent_derivation_route_and_service_seam_removed( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -11820,25 +12019,13 @@ async def test_derivation_agent_allows_warning_report_without_acknowledgement_an f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots/" f"{snapshot['id']}/derive-submission-artifact-policy" ) - first, second = await asyncio.gather( - project_client.post(endpoint, headers=auth_headers()), - project_client.post(endpoint, headers=auth_headers()), - ) + response = await project_client.post(endpoint, headers=auth_headers()) - assert inspect.iscoroutinefunction( - ProjectService.run_submission_artifact_policy_derivation_agent - ) - assert {first.status_code, second.status_code} == {200, 201} - assert first.json()["id"] == second.json()["id"] - assert first.json()["source_snapshot_id"] == snapshot["id"] - assert first.json()["source_snapshot_hash"] == snapshot["bundle_hash"] - assert first.json()["derivation_source"] == "agent_derivation" - assert first.json()["policy_body"]["artifact_hash_algorithm"] == "sha256" - assert first.json()["policy_body"]["manifest_required"] is True - assert first.json()["policy_body"]["artifact_hash_required"] is True + assert response.status_code == 404 + assert not hasattr(ProjectService, "run_submission_artifact_policy_derivation_agent") -async def test_derivation_agent_requires_agent_sufficiency_report( +async def test_submission_artifact_policy_removed_agent_derivation_route_discloses_no_state( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -11863,13 +12050,10 @@ async def test_derivation_agent_requires_agent_sufficiency_report( response = await project_client.post(endpoint, headers=auth_headers()) assert manual_report["agent_name"] is None - assert response.status_code == 422 - assert ( - "guide sufficiency report is required before policy derivation" in response.json()["detail"] - ) + assert response.status_code == 404 -async def test_derivation_agent_uses_verified_sources_and_replays_exact_policy( +async def test_submission_artifact_policy_removed_agent_route_cannot_read_verified_sources( project_client: AsyncClient, deterministic_project_agent_runtime: None, ) -> None: @@ -11907,17 +12091,8 @@ async def test_derivation_agent_uses_verified_sources_and_replays_exact_policy( created = await project_client.post(endpoint, headers=auth_headers()) replayed = await project_client.post(endpoint, headers=auth_headers()) - assert created.status_code == 201, created.text - assert replayed.status_code == 200, replayed.text - assert replayed.json()["id"] == created.json()["id"] - assert created.json()["derivation_source"] == "agent_derivation" - assert created.json()["derivation_agent_name"] - assert created.json()["derivation_agent_version"] - assert created.json()["source_material_refs"] - assert all( - ref.startswith("artifact-content:") and "#extraction-usage:" in ref - for ref in created.json()["source_material_refs"] - ) + assert created.status_code == 404 + assert replayed.status_code == 404 async def test_manual_submission_artifact_policy_rejects_agent_provenance_fields( @@ -12008,7 +12183,7 @@ async def test_manual_submission_artifact_policy_rejects_agent_provenance_fields assert update_response.json()["detail"][0]["loc"] == ["body", "derivation_agent_name"] -async def test_derivation_agent_validates_existing_policy_integrity_before_reuse( +async def test_submission_artifact_policy_removed_agent_route_cannot_reuse_existing_policy( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -12057,11 +12232,10 @@ async def test_derivation_agent_validates_existing_policy_integrity_before_reuse ) blocked = await project_client.post(endpoint, headers=auth_headers()) - assert blocked.status_code == 409 - assert "policy body hash mismatch" in blocked.json()["detail"] + assert blocked.status_code == 404 -async def test_manual_submission_artifact_policy_update_rejects_agent_derived_row( +async def test_submission_artifact_policy_human_cannot_invoke_removed_agent_route( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -12084,51 +12258,7 @@ async def test_manual_submission_artifact_policy_update_rejects_agent_derived_ro f"{snapshot['id']}/derive-submission-artifact-policy" ) derived = await project_client.post(endpoint, headers=auth_headers()) - assert derived.status_code == 201, derived.text - - update_response = await project_client.patch( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" - f"{derived.json()['id']}", - headers=auth_headers(), - json={ - "expected_policy_hash": derived.json()["policy_hash"], - "successor_policy_version": "manual-v2", - "policy_body": project_submission_artifact_policy_body( - artifact_path="adjusted/output.json" - ), - }, - ) - - assert update_response.status_code == 409 - assert "agent-derived policy summaries are immutable" in update_response.json()["detail"] - - summary_response = await project_client.patch( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" - f"{derived.json()['id']}", - headers=auth_headers(), - json={ - "expected_policy_hash": derived.json()["policy_hash"], - "successor_policy_version": "manual-v3", - "change_summary": "Admin-edited generated summary.", - }, - ) - - assert summary_response.status_code == 409 - assert "agent-derived policy summaries are immutable" in summary_response.json()["detail"] - - approved = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" - f"{derived.json()['id']}/approve", - headers=auth_headers(), - json={"approval_note": "Approval note must not overwrite generated summary."}, - ) - assert approved.status_code == 200, approved.text - - async with db_session.get_session_factory()() as session: - persisted_policy = await session.get(SubmissionArtifactPolicy, derived.json()["id"]) - - assert persisted_policy is not None - assert persisted_policy.change_summary == derived.json()["change_summary"] + assert derived.status_code == 404 async def test_agent_derived_policy_approval_revalidates_server_owned_provenance( @@ -12175,7 +12305,7 @@ async def test_agent_derived_policy_approval_revalidates_server_owned_provenance assert "runtime provenance is not server-owned" in response.json()["detail"] -async def test_derivation_agent_idempotency_uses_server_owned_policy_version( +async def test_submission_artifact_policy_removed_agent_route_performs_no_runtime_calls( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -12247,18 +12377,9 @@ async def derive_submission_artifact_policy( project_client.post(endpoint, headers=auth_headers()), ) - assert {first.status_code, second.status_code} == {200, 201} - assert first.json()["id"] == second.json()["id"] - assert first.json()["policy_version"].startswith("agent-") - assert first.json()["policy_version"] != "provider-version-1" - assert second.json()["policy_version"] != "provider-version-2" - assert first.json()["derivation_agent_name"] == SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_NAME - assert ( - first.json()["derivation_agent_version"] - == SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_VERSION - ) - assert "ProjectOwnerApprovedDerivationAgent" not in first.text - assert "fake-v0" not in first.text + assert first.status_code == 404 + assert second.status_code == 404 + assert runtime.calls == 0 async with db_session.get_session_factory()() as session: policies = ( await session.scalars( @@ -12270,7 +12391,7 @@ async def derive_submission_artifact_policy( ) ).all() - assert len(policies) == 1 + assert policies == [] async def test_activation_revalidates_agent_derived_policy_provenance( diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index d6057b623..ddffd4b97 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -907,8 +907,9 @@ Historically, AUTH-12B extended the registry to an eighth identity, `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, and `project.setup_run.update`. -AUTH-12E activates only `project.guide_sufficiency.run`; the other three -memberships remain planned and unavailable. The active action can be resolved +AUTH-12E activates `project.guide_sufficiency.run`, and AUTH-12F3 activates +`project.submission_artifact_policy.derive`; the other two memberships remain +planned and unavailable. Each active action can be resolved for this fixed service only by an internal command carrying exact setup-run, expected-step, task/correlation, project, guide, snapshot, generation, stale output, and material custody. It is not admitted through the public HTTP route, @@ -1359,18 +1360,21 @@ refused after any 12E replay or attributed sufficiency provenance exists; do not delete replay, product, or authority evidence to force rollback. Migration `0057_submission_policy_authority` installs submission-policy -PREP, replay, and nullable provenance custody only. Existing submission, +PREP, replay, and nullable provenance custody. Migration +`0059_policy_execution_claim` adds the fixed-service derivation execution claim. +Existing submission, effective, and pre-submit policy rows remain readable with all authority fields -null; do not backfill invented authority. The replay reservation is mutable only -for its single `pending -> committed` completion, remains in the caller's root -transaction, and must not be committed or rolled back by the AUTH helper. A -pending reservation is still authority evidence and intentionally blocks -downgrade. Any audit event using the submission-policy mutation resource type, +null; do not backfill invented authority. Human replay custody permits only +`pending -> committed`. Fixed-service derivation commits `reserved` before +material or agent I/O, then uses fresh final PREP and atomically advances +`reserved -> pending -> committed` with the protected product mutation. A +reserved or pending row is durable custody and intentionally blocks downgrade. +Any audit event using the submission-policy mutation resource type, including denied evidence, blocks downgrade independently. 12F2 activates only manual human create/update. Each update appends a separately authorized -successor and supersedes its exact hash-selected predecessor atomically; derive -and approve remain planned. Operators must not treat the shared schema as wider -activation. +successor and supersedes its exact hash-selected predecessor atomically. 12F3 +activates derive only for the fixed `workstream.project.setup` service; approve +remains planned. Operators must not treat the shared schema as wider activation. ## Draft review and revision policy authorization diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 14b327dfa..6c79bab46 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -1086,7 +1086,7 @@ with exact setup custody and no matched human grant. | `project.guide_sufficiency.run` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | | `project.guide_sufficiency.warnings.acknowledge` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | | `project.submission_artifact_policy.create` (active) | `project.effective_policy.manage` | `WS-AUTH-001-12F2` | -| `project.submission_artifact_policy.derive` | `project.effective_policy.manage` | `WS-AUTH-001-12F3` | +| `project.submission_artifact_policy.derive` (active) | `project.effective_policy.manage` | `WS-AUTH-001-12F3` | | `project.submission_artifact_policy.update` (active) | `project.effective_policy.manage` | `WS-AUTH-001-12F2` | | `project.submission_artifact_policy.approve` | `project.effective_policy.manage` | `WS-AUTH-001-12F4` | | `project.post_submit_checker_policy.approve` | `project.effective_policy.manage` | `WS-AUTH-001-12G` | @@ -1115,17 +1115,21 @@ policy generation, actor/link and grant-or-fixed-service custody, and current root transaction. Approval also binds the immutable default-catalogue manifest, ordered and disabled entry configuration digests, compiler/bundle schema, and compiled/effective output hashes. Its replay reservation distinguishes human -idempotency from fixed setup-service task custody and permits only -`pending -> committed`; it does not perform a product mutation or own commit. +idempotency from fixed setup-service task custody. Manual mutations permit only +`pending -> committed`. Fixed-service derivation uses the 12F3 one-way +`reserved -> pending -> committed` state machine: `reserved` is committed before +material or agent I/O, while the final two transitions commit atomically with +the derived policy, final AUTH evidence, and setup output. Migration `0057_submission_policy_authority` preserves existing product rows in the all-null unattributed shape until their owning route cutovers. 12F2 now activates only manual human create/update. Manual update appends a separately authorized successor, binds predecessor hash and successor identity through PREP/replay, and supersedes the predecessor in the same root transaction. Diagnostic sufficiency, legacy role strings, services, contributors, and -agent-derived rows cannot authorize this exception. Derive and approve remain -planned. Any replay -row—including pending—or attributed provenance blocks downgrade. Any submission-policy +agent-derived rows cannot authorize this exception. Fixed-service derive is +active under 12F3; human approval remains planned for 12F4. Any durable +execution claim or replay row—including reserved or pending—or attributed +provenance blocks downgrade. Any submission-policy authorization audit event, including denied evidence, also blocks downgrade so the admitted evidence vocabulary is never removed while referenced. diff --git a/docs/spec_chunk_3_project_guide_foundation.md b/docs/spec_chunk_3_project_guide_foundation.md index 706339650..61d86dc69 100644 --- a/docs/spec_chunk_3_project_guide_foundation.md +++ b/docs/spec_chunk_3_project_guide_foundation.md @@ -135,7 +135,6 @@ Adds protected v1 routes: - `POST /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}/acknowledge-warnings` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies` -- `POST /api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots/{source_snapshot_id}/derive-submission-artifact-policy` (legacy until 12F3 removes it) - `PATCH /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}/approve` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/activate` @@ -143,8 +142,8 @@ Adds protected v1 routes: These routes require an actor role allowed to manage project setup. -Normal project setup does not depend on manually calling the sufficiency or -derivation routes. Creating guide-source snapshot metadata enqueues the Celery +Normal project setup does not depend on manually requesting sufficiency, and +there is no public derivation route. Creating guide-source snapshot metadata enqueues the Celery project setup pipeline; guide creation alone does not. The pipeline runs guide sufficiency first and only continues to submission artifact policy derivation when sufficiency is not blocked. @@ -157,9 +156,11 @@ automatic setup run. Both paths use the canonical same-generation ART material. - `POST /api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots/{source_snapshot_id}/run-sufficiency-agent` -Until `WS-AUTH-001-12F3` merges, the public -`derive-submission-artifact-policy` repair endpoint remains legacy behavior -using its current actor and setup-role checks. After that merge, automatic +`WS-AUTH-001-12F3` removes the public inline derivation endpoint. Submission +artifact policy derivation runs only in the asynchronous project-setup worker +under fresh `workstream.project.setup` fixed-service authority +using exact active service-profile, identity-link, action, and setup-custody +checks. After that merge, automatic derivation is owned only by the fixed `workstream.project.setup` service after current lineage and sufficiency are revalidated; a Project Manager cannot invoke the agent inline. From b0f73af915317bba14f7187bd07c74d9f87985e8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 7 Aug 2026 06:33:34 +0100 Subject: [PATCH 2/4] docs(auth): record 12f3 pull request --- .../WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md | 2 +- .../WS-AUTH-001-workstream-authorization-service/STATUS.md | 2 +- .../WS-AUTH-001-12F3-submission-policy-service-derivation.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 26d2c7b03..a52a1de83 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -71,7 +71,7 @@ stopped. | `WS-AUTH-001-12F` | Submission Artifact Policy Planning Parent | L1 | Split after failed L1 pre-start review; zero activation | | `WS-AUTH-001-12F1` | Submission Policy Authority Foundation | L1 | Merged through PR #286; zero activation | | `WS-AUTH-001-12F2` | Manual Submission Policy Drafts | L1 | Merged through PR #292 as `81f281bd` | -| `WS-AUTH-001-12F3` | Fixed-Service Policy Derivation | L1 | Ready for PR; internal L1 reviews passed | +| `WS-AUTH-001-12F3` | Fixed-Service Policy Derivation | L1 | PR #295; external checks pending | | `WS-AUTH-001-12F4` | Submission Policy Approval Chain | L1 | Proposed after 12F3 | | `WS-AUTH-001-12G` | Post-Submit Checker Policy Mutation Cutover | L1 | Proposed after 12F4 | | `WS-AUTH-001-12H` | Guide Activation Cutover | L1 | Proposed after 12B2 and the owning CON clean cut | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index c209b7e61..b607e7709 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -183,7 +183,7 @@ human-merged. | `WS-AUTH-001-12F` | Planning split | `codex/ws-auth-001-12f-submission-artifact-policy` | - | Combined contract failed required L1 pre-start review; parent now activates nothing and delegates to 12F1-12F4. | | `WS-AUTH-001-12F1` | Merged | `codex/ws-auth-001-12f1-submission-policy-foundation` | #286 | Submission-policy PREP, replay, provenance, and audit custody foundation merged as `5a4186cc`; zero activation. | | `WS-AUTH-001-12F2` | Merged | `codex/ws-auth-001-12f2-manual-submission-policy` | #292 | Governed Project Manager append-only manual-draft create/update cutover merged as `81f281bd`. | -| `WS-AUTH-001-12F3` | Ready for PR | `codex/ws-auth-001-12f3-service-derivation` | - | Fixed setup-service derivation and asynchronous executor cutover; internal L1 reviews passed. | +| `WS-AUTH-001-12F3` | External checks | `codex/ws-auth-001-12f3-service-derivation` | #295 | Fixed setup-service derivation and asynchronous executor cutover; internal L1 reviews passed. | | `WS-AUTH-001-12F4` | Proposed | - | - | Project Manager approval and atomic effective/pre-submit policy chain. | | `WS-AUTH-001-12G` | Proposed | - | - | Post-submit checker policy approval/correction cutover after 12F4. | | `WS-AUTH-001-12H` | Proposed | - | - | Terminal guide activation after 12B2 and the owning CON clean cut. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md index 6a46dc051..cb524749d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F3-submission-policy-service-derivation.md @@ -2,8 +2,8 @@ ## Status and prerequisite -Implementation complete and ready for hosted CI/external review after merged -12F2. Risk: L1. Human merge remains required. +Implementation complete in PR #295; hosted CI/external review is pending after +merged 12F2. Risk: L1. Human merge remains required. ## Goal From b8738eaaf72247fffe385b073e9b8149658a44b1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 7 Aug 2026 07:06:02 +0100 Subject: [PATCH 3/4] test(auth): restore 12f3 contract parity --- ...-AUTH-001-12F3-external-review-response.md | 36 +++++++++++++++++++ .../WS-AUTH-001-12F3-pr-trust-bundle.md | 9 ++++- backend/tests/test_alembic.py | 1 + backend/tests/test_api_controls.py | 8 ++--- backend/tests/test_audit.py | 1 + 5 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md new file mode 100644 index 000000000..3b1ae1759 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md @@ -0,0 +1,36 @@ +# External Review Response: WS-AUTH-001-12F3 + +## Comments addressed + +- CodeRabbit reported no actionable review comments. +- Hosted Backend exposed stale OpenAPI inventory expectations after removal of + the public derivation route; the exact route and protected-route inventories + now describe the intended hidden fixed-service surface. +- Hosted Backend exposed stale active-action audit parity; the newly active + fixed-service derivation action is now part of the exact allowed-action set. +- Hosted Backend exposed historical migration selectors that lost the derive + action when its catalogue owner moved from `AUTH_12F` to `AUTH_12F3`; the + frozen project-mutation owner set now includes the successor owner so all + eighteen migration-0041 action pairs and downgrade exclusions remain exact. + +## Comments deferred + +None. + +## Human decisions needed + +None before review. Human approval remains required to merge PR #295. + +## Commands rerun + +- Ruff on the three corrected test modules. +- Focused OpenAPI and action-aware audit parity tests. +- Four focused PostgreSQL migration regressions covering migration-0041 action + parity, downgrade custody, authorization-action evidence, and bootstrap + authority passed. +- Full repository coverage remains assigned to exact-head GitHub Actions. + +## Remaining risks + +None identified from the external findings. The corrections change only exact +contract expectations; they do not weaken authorization or CI. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md index 2f0ad0fd1..82358a26c 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md @@ -101,7 +101,14 @@ docs, and CI-integrity reviews passed after findings were fixed. ## External review -Pending CodeRabbit and exact-head GitHub Actions checks. +CodeRabbit reported no actionable comments. The first hosted Backend run found +three stale contract expectations after the fixed-service cutover; they were +corrected without changing authorization behavior or CI. Fresh exact-head +GitHub Actions checks are required after the corrective push. + +The corrective local evidence passed: Ruff, two focused OpenAPI/audit tests, +and four focused PostgreSQL migration regressions. CI-integrity re-review passed +with no required fixes. ## Remaining risks diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 77178f7f8..85980e645 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -97,6 +97,7 @@ ActionOwner.AUTH_12E, ActionOwner.AUTH_12F, ActionOwner.AUTH_12F2, + ActionOwner.AUTH_12F3, ActionOwner.AUTH_12G, ActionOwner.AUTH_12H, } diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index a2ebd3baf..0ebe40d9f 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,13 +439,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 78 + assert len(route_inventory) == 77 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "eebf8e5d7fc791a4513b80f6da3fa5cb724eb26e4ce46af8306e1836854a9fd2" + "9fa013de5446a81a87bfda1f4a31b2fa958a802bbe2e151a6f283d857a7786d9" ) - assert len(protected_inventory) == 76 + assert len(protected_inventory) == 75 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "e40f11c7580bfd48b3554add18a41b913104096c480c91b11c8f33ac9d50ee37" + "be16e83bdfe03b493f2016b6f85156eea2db172a09f03ed27a65002921f1944c" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index ffd842673..8eac4fe6b 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -207,6 +207,7 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, From 63fc6e194b3dd193c7e282aeab285e423ff88cc3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 7 Aug 2026 07:22:28 +0100 Subject: [PATCH 4/4] test(auth): compose policy agent in api drill --- .../reviews/WS-AUTH-001-12F3-external-review-response.md | 7 +++++++ .../reviews/WS-AUTH-001-12F3-pr-trust-bundle.md | 5 +++++ backend/scripts/api_contract_e2e.py | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md index 3b1ae1759..a78742918 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-external-review-response.md @@ -12,6 +12,11 @@ action when its catalogue owner moved from `AUTH_12F` to `AUTH_12F3`; the frozen project-mutation owner set now includes the successor owner so all eighteen migration-0041 action pairs and downgrade exclusions remain exact. +- The next hosted run passed all five semantic lanes, then the real-API drill + exposed stale test composition: its deterministic project-agent runtime was + not installed in the new 12F3 policy-derivation owner. The drill now installs + that runtime in `submission_policy_mutation_service` as well; production + fail-closed adapter behavior is unchanged. ## Comments deferred @@ -28,6 +33,8 @@ None before review. Human approval remains required to merge PR #295. - Four focused PostgreSQL migration regressions covering migration-0041 action parity, downgrade custody, authorization-action evidence, and bootstrap authority passed. +- The API-contract support module selector passed (17 tests), together with + Ruff and compile validation of the real-API drill. - Full repository coverage remains assigned to exact-head GitHub Actions. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md index 82358a26c..29aed7d56 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F3-pr-trust-bundle.md @@ -110,6 +110,11 @@ The corrective local evidence passed: Ruff, two focused OpenAPI/audit tests, and four focused PostgreSQL migration regressions. CI-integrity re-review passed with no required fixes. +The following hosted run passed every semantic lane, then found that the +real-API drill had not injected its deterministic agent into the new 12F3 +policy-derivation module. Test composition was corrected without adding a +production fallback; the API-contract support selector passes locally. + ## Remaining risks Low: execution-fence and worker composition scaffolding parallels sufficiency; diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 589e7525d..a5a867ae5 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -936,6 +936,9 @@ async def publish_verification_job(job_id: str) -> None: SubmissionArtifactPolicyDerivationResult, ) from app.modules.projects import service as project_service_module + from app.modules.projects import ( + submission_policy_mutation_service as submission_policy_mutation_service_module, + ) from app.modules.projects import ( sufficiency_mutation_service as sufficiency_mutation_service_module, ) @@ -968,6 +971,9 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): agent_runtime = E2EProjectGuideAgentRuntime() project_service_module.get_project_guide_agent_runtime = lambda: agent_runtime sufficiency_mutation_service_module.get_project_guide_agent_runtime = lambda: agent_runtime + submission_policy_mutation_service_module.get_project_guide_agent_runtime = ( + lambda: agent_runtime + ) setup_worker_result = await asyncio.to_thread( run_pre_submit_setup_pipeline, project_id,