diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index ed82504c6..89d7fbe32 100644 --- a/.agent-loop/CURRENT_STATE.md +++ b/.agent-loop/CURRENT_STATE.md @@ -28,7 +28,7 @@ authority; these records do not grant or withhold it. | Initiative | Durable state on `main` | Remaining boundary | |---|---|---| -| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Boundary foundation and TASK, PROJECT, and CHECKER public facts are merged through PR #320; no product behavior was activated | `02D`, the ART hidden-preparation public API and private-edge migration, is the next durable boundary | +| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Boundary foundation and TASK, PROJECT, CHECKER, and ART hidden-preparation public facts are complete through `WS-ARCH-001-02D`; no product behavior was activated | `WS-ARCH-001-02E` is the ART ready-admission consumption and binding public capability | | [WS-ART-001](initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md) | Active delivery initiative; verified ready-admission publication and hidden preparation are merged through `04C2` | Replace the non-executable 05-wave XINT/ART contracts with WS-ARCH-001 split public-API contracts before activation, Submission consumption, or live cutover | | [WS-AUTH-001](initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md) | Active delivery initiative; project-policy authority and unified compilation authorization are merged through `12I` | POL-03B consumes 12I next; remaining AUTH activation chunks wait for their exact hidden owner behavior | | [WS-CON-001](initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md) | Active delivery initiative; policy persistence and shared lifecycle audit are merged | Complete hidden services, contribution records, conditional awards, fulfillment, and reconciliation after named AUTH and REV gates | diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md index ac33f90db..c0345669a 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md @@ -8,8 +8,8 @@ | `WS-ARCH-001-02A` | TASKS task/assignment/predecessor and Submission public facts/ports | L1 | Merged PR #314 | | `WS-ARCH-001-02B` | PROJECTS locked guide and submission-policy public facts/ports | L1 | Merged PR #315 | | `WS-ARCH-001-02C` | CHECKERS effective pre-submit plan and bounded execution-result public facts/ports | L1 | Merged PR #320; no contributor preparation action or public route activated | -| `WS-ARCH-001-02D` | ART hidden preparation public API and private-edge migration | L1 | Next durable boundary | -| `WS-ARCH-001-02E` | ART ready-admission consumption and binding public capability | L1 | Proposed after 02D | +| `WS-ARCH-001-02D` | ART hidden preparation public API and private-edge migration | L1 | Complete; production remains deny-only | +| `WS-ARCH-001-02E` | ART ready-admission consumption and binding public capability | L1 | Next durable boundary | | `WS-ARCH-001-02F` | TASK-owned immutable Submission command and hidden composed transaction | L1 | Proposed after 02E | | `WS-ARCH-001-02G` | AUTH contributor preparation activation after the complete hidden path | L1 | Proposed after 02F | | `WS-ARCH-001-02H` | AUTH human/fixed-service consumption activation | L1 | Proposed after 02G | diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/INTENT.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/INTENT.md index 08f60245f..e916529ac 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/INTENT.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/INTENT.md @@ -12,7 +12,9 @@ module and every cross-module call must use a typed public API. - The nine business modules and three supporting modules have one canonical ownership map. - Cross-module runtime imports use only the target module's `api` package. -- Concrete implementations meet only in the application composition root. +- Concrete implementations meet only in the application composition root or + the exact same-owner `backend/app/adapters//__init__.py` composition + root; nested adapters and cross-owner private imports remain prohibited. - Existing private-import debt is frozen as exact source-to-target edges. - Every feature chunk repairs the capabilities and debt edges it touches. - No debt count grows, and final closure requires an empty private-import diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/PLAN.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/PLAN.md index f02cf961d..3e1f188e1 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/PLAN.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/PLAN.md @@ -22,7 +22,10 @@ canonical module map opaque capability Protocols, and ports—not implementation. 4. The caller supplies server-owned canonical facts; it never receives the target's ORM model or repository. -5. Concrete implementations meet only in the application composition root. +5. Concrete implementations meet only in the application composition root or + the exact same-owner `backend/app/adapters//__init__.py` composition + root. That adapter root constructs typed public ports; the exception never + extends to nested adapter files or cross-owner private imports. 6. The application composition root opens the SQLAlchemy transaction/unit of work and constructs transaction-bound public-port implementations. The owning application command coordinates those injected ports without diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md index 3e5e570e2..2ec6a75a4 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md @@ -18,8 +18,13 @@ - WS-ARCH-001-02C merged through PR #320. It exposes CHECKER-owned effective-plan and bounded execution-result contracts without activating the contributor preparation route. -- Next durable boundary: WS-ARCH-001-02D only, the ART hidden-preparation public - API and private-edge migration. Open pull requests show transient ownership. +- WS-ARCH-001-02D is complete in this change. It moves the hidden preparation + route to delivery composition, exposes the bounded ART request/result/command + API, consumes TASK/PROJECT/CHECKER public capabilities, keeps AUTH handles + opaque, and preserves deny-only availability. +- Next durable boundary: WS-ARCH-001-02E only, the ART + ready-admission consumption and binding public capability. Open pull requests + show transient ownership. - Repository housekeeping after PR #315 found no competing clean-up initiative: WS-ARCH-001 remains the general boundary owner, WS-AUTH-003 owns AUTH-specific debt, and test-structure repairs remain incremental with the diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02D-art-preparation-public-api.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02D-art-preparation-public-api.md index d19a29c6d..6e4ad5e4f 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02D-art-preparation-public-api.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02D-art-preparation-public-api.md @@ -26,6 +26,10 @@ edges are removed and final authority remains transaction-bound. L1 +## Merge state + +- Outcome on merge: `complete` + ## SLA P1 @@ -39,22 +43,73 @@ PROJECT, and CHECKER public fact manifests. ```text backend/app/modules/artifacts/api/** +backend/app/modules/artifacts/authorization.py +backend/app/modules/artifacts/preparation.py +backend/app/modules/artifacts/router.py +backend/app/modules/artifacts/pre_submit_evidence.py backend/app/modules/artifacts/submission_admission.py backend/app/modules/artifacts/submission_authorization.py +backend/app/modules/artifacts/submission_materialization.py backend/app/modules/artifacts/schemas.py +backend/app/modules/checkers/api/__init__.py +backend/app/modules/checkers/api/pre_submit.py +backend/app/modules/checkers/pre_submit_execution.py backend/app/adapters/artifacts/__init__.py +backend/app/adapters/checkers/__init__.py +backend/app/adapters/projects/__init__.py +backend/app/adapters/tasks/__init__.py +backend/app/api/deps/authorization.py backend/app/interfaces/artifact_operations.py backend/app/modules/tasks/router.py +backend/app/api/router.py +backend/app/api/routes/artifact_submissions.py backend/tests/architecture/test_module_boundaries.py +backend/scripts/module_boundaries.py +backend/scripts/behavior_ownership.py +backend/tests/test_behavior_ownership.py +backend/scripts/run_test_lanes.py +backend/tests/test_artifact_architecture.py backend/tests/test_submission_bundle_admission.py backend/tests/test_default_pre_submit_execution.py +backend/tests/test_effective_pre_submit_execution.py +backend/tests/test_authorization.py +backend/tests/test_pre_submit_evidence_relock.py +backend/tests/pre_submit_test_helpers.py .ci/module-boundaries/private-edge-debt.v1.json +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json .ci/behavior-ownership/** +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02D-art-preparation-public-api.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md +.agent-loop/CURRENT_STATE.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02D-resource-manifest.md docs/spec_artifact_storage_service.md +.agent-loop/policies/architecture-boundaries.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/INTENT.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/PLAN.md +docs/architecture_lockdown.md +docs/operations_backend_testing.md +backend/app/adapters/README.md ``` +## Public and private contract split + +- `artifacts.api` owns only dependency-safe route-facing request/result/error + values and capability ports. It does not re-export private ART scratch, + inspection, custody, manifest, pass-capability, repository, session, or + provider types. +- Process-local sealed preparation and materialization values move out of the + repository-global `app.interfaces.artifact_operations` surface and remain + ART-private with one physical definition. +- AUTH handles remain opaque `object` values at ART seams. ART does not import, + reconstruct, serialize, or inspect AUTH's private handle class; the concrete + transaction-bound AUTH adapter owns validation and consumption. +- The composition root may import concrete TASK, PROJECT, CHECKER, AUTH, and + ART implementations to construct public ports. Route-facing and product + module code may depend only on owner public APIs. + ## Not allowed Action activation; public OpenAPI exposure; Submission creation/binding; @@ -63,21 +118,33 @@ cross-module imports; serialized prepared handles; compatibility facades. ## Acceptance criteria -- [ ] The route and composition code depend on `artifacts.api`; ART depends on - owner public APIs only. -- [ ] The public port shape places preflight before byte acceptance and final +- [x] The route and composition code depend on `artifacts.api`; ART depends on + owner public APIs only. The composition root may instantiate concrete + implementations but may not hide a service locator or second factory + behind the public API. +- [x] The public port shape places preflight before byte acceptance and final prepared-authority consumption in the durable-intent transaction before capacity, put attempt, or provider I/O, but production remains deny-only and no successful prepared handle is issued or consumed in this chunk. -- [ ] Planned-action denial, concealment, exact replay, stale lineage, and +- [x] Planned-action denial, concealment, exact replay, stale lineage, and cross-resource attempts preserve zero partial effect and zero provider I/O. -- [ ] `artifact.submission_bundle.prepare` remains planned/unavailable and the +- [x] `artifact.submission_bundle.prepare` remains planned/unavailable and the route remains hidden. -- [ ] Every touched private edge is removed. -- [ ] Submission-preparation types in - `app.interfaces.artifact_operations` migrate to `artifacts.api`; no - parallel legacy/public ART contract remains. -- [ ] Record the exact preparation resource/port manifest in +- [x] Every touched private edge is removed. +- [x] Route-facing submission-preparation types in + `app.interfaces.artifact_operations` migrate to `artifacts.api`. + Process-local sealed types become ART-private; no parallel legacy/public + ART contract remains. +- [x] After byte materialization and before evidence persistence, ART re-locks + TASK assignment/predecessor facts and PROJECT locked-policy facts through + their public ports and compares them with the original CHECKER plan + lineage. Stale lineage fails before durable evidence or provider I/O. +- [x] CHECKER execution is injected at the composition root and returns only + public CHECKER result facts. ART constructs and retains its own custody + facts; neither module imports the other's private implementation. +- [x] `artifacts.api` imports no private module and exposes no ORM, session, + repository, provider, scratch path, byte buffer, or serialized handle. +- [x] Record the exact preparation resource/port manifest in `.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02D-resource-manifest.md`. ## Verification commands diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02D-resource-manifest.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02D-resource-manifest.md new file mode 100644 index 000000000..238c4d097 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02D-resource-manifest.md @@ -0,0 +1,74 @@ +# WS-ARCH-001-02D ART Preparation Resource Manifest + +## Public entry capability + +`app.modules.artifacts.api.SubmissionBundlePreparationCommand.prepare` accepts +one `SubmissionBundlePreparationRequest` and returns one +`SubmissionBundlePreparationResult`. + +The request carries only: + +- dependency-safe `ActorIdentityFacts`; +- request and correlation identifiers; +- task, assignment, predecessor, and idempotency identifiers; +- bounded packet text and media type; +- the request-local asynchronous byte source. + +It carries no authorization context, prepared handle, database session, ORM +row, provider coordinate, scratch path, inspection result, custody value, or +durable pass capability. The route remains hidden from OpenAPI and the +production contributor authority remains deny-only. + +## Owner ports consumed by ART + +| Owner | Public capability | Facts used by ART | +|---|---|---| +| TASK | `TaskSubmissionContextPort.lock_submission_context` | Exact task, assignment, contributor, lifecycle kind/status, predecessor version, and task-stamped project-policy references | +| PROJECT | `ProjectLockedPolicyContextPort.lock_locked_policy_context` | Exact guide/source lineage, effective artifact policy, compiled pre-submit policy, statuses, and canonical policy JSON | +| CHECKER | `EffectivePreSubmissionPlanningPort.compile_effective_plan` | Immutable effective plan and plan identity | +| CHECKER | `PreSubmissionExecutionFacts` | Bounded ordered result facts only; ART retains its own byte and scratch custody | +| AUTH | `ActorIdentityFacts` plus ART-private opaque authorization port | Active actor and identity-link references; process-local prepared handles remain opaque `object` values and are never serialized | + +## Lock and authority order + +1. Contributor preflight occurs before the runtime opens or request bytes are + read. +2. TASK and then PROJECT facts are locked through their public ports, and the + CHECKER plan is compiled from those exact facts. +3. ART prepares and inspects bytes in bounded scratch. +4. Fixed-service materialization authority is consumed before workspace byte + access or checker execution. +5. After execution and scratch cleanup, contributor authority is revalidated, + then TASK and PROJECT facts are locked again in the evidence transaction. +6. Evidence persists only if assignment, predecessor, guide, policy, plan, + byte, manifest, and result facts remain exact. +7. Fresh final contributor authority is prepared and consumed in the durable + put-intent transaction before capacity reservation, put-attempt creation, + or provider I/O. + +## Private custody retained by ART + +These values are deliberately not public contracts: + +- `PreparedBundleMaterializationRequest`; +- prepared artifact and generation binding; +- archive inspection and semantic manifest; +- scratch workspace and reader; +- `PreSubmitExecutionCustody`; +- `PreSubmitPassCapability`; +- durable put and ready-admission internals. + +## Composition + +Delivery code lives in `app.api.routes.artifact_submissions`. Owner adapter +packages bind TASK, PROJECT, CHECKER, and ART implementations to public ports. +The boundary gate permits an owner adapter to import only its own module's +private implementation; cross-owner private imports remain ledgered debt and +new debt remains prohibited against the protected base. + +## Denial guarantees + +Unavailable authority, changed TASK/PROJECT lineage, mismatched plans or +results, copied/replayed/wrong opaque handles, and invalid media types fail +without publishing a successful preparation. Authority handles do not enter +request schemas, Celery payloads, provider contracts, logs, or persistence. diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md index 5b96a732d..17f1d99e0 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md @@ -66,14 +66,6 @@ backend/app/modules/artifacts/service.py app.modules.authorization.catalogue app.modules.authorization.prepared app.modules.authorization.runtime -backend/app/modules/artifacts/submission_admission.py - app.modules.authorization.prepared -backend/app/modules/artifacts/submission_authorization.py - app.modules.authorization.prepared - app.modules.authorization.runtime -backend/app/modules/artifacts/submission_materialization.py - app.modules.authorization.catalogue - app.modules.authorization.prepared backend/app/modules/projects/authorization_reads.py app.modules.authorization.catalogue app.modules.authorization.kernel @@ -115,8 +107,6 @@ backend/app/modules/projects/sufficiency_mutation_service.py app.modules.authorization.runtime backend/app/workers/project_setup.py app.modules.authorization.prepared -backend/app/modules/tasks/router.py - app.modules.authorization.runtime ``` The ledger validator decodes numeric Markdown entities before comparing exact diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json index aa0964b01..be14dfaa6 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json @@ -218,11 +218,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "6c02f75335bb5ad6bb023b7d80b0c8801c849feae308fa4f5446d38d6058b1ed", - "end_line": 13434, + "content_sha256": "ebb3b0ede78653a373c17904bc29d40da7ed93e96e0416e0742079386ca7c7f5", + "end_line": 13421, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 13434, + "observed_lines": 13421, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -230,11 +230,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "ca1f6ec87557ec015061506dd7a1e9562b129bdde8ce82150664c459756566a3", - "end_line": 1728, + "content_sha256": "ce68513010bfa3af071aa5b73289a1d793a2c20593e7099726787b03e058b5a7", + "end_line": 1720, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1728, + "observed_lines": 1720, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -531,50 +531,50 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "951b44cc07e36002118fe93b7974e8d65851b0e2c3cec89a3031cbe42b014e2d", - "end_line": 8534, + "end_line": 8521, "hard_limit": 120, "kind": "test_function", "observed_lines": 140, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_actor_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8395 + "start_line": 8382 }, { "capability": "unassigned_legacy_auth", "content_sha256": "569084d6de89ff9eae71d526fc6c157aea7638f55ca93ad128b721fbda339be6", - "end_line": 9024, + "end_line": 9011, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_resource_digest_alone_rejects_substituted_role_and_disposition", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8903 + "start_line": 8890 }, { "capability": "unassigned_legacy_auth", "content_sha256": "90b8b670b4d277209617cc1aa794188b4fb3cd9d894b69d5dc3d0adfc885f6ed", - "end_line": 9252, + "end_line": 9239, "hard_limit": 120, "kind": "test_function", "observed_lines": 132, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_revoke_stages_complete_state_and_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9121 + "start_line": 9108 }, { "capability": "unassigned_legacy_auth", "content_sha256": "78d9bf3df08e5633e9a75720b1e4bf5b7d7b24019bc392b4cb7496a9ac5e5e8e", - "end_line": 11086, + "end_line": 11073, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_authorization_locks_refresh_cached_actor_lifecycle_state", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10965 + "start_line": 10952 }, { "capability": "unassigned_legacy_auth", @@ -603,62 +603,50 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "05d1b020ecff0f9bc0a0567adc07f5b31a2f9dfb7828ae3ad34d4e1e7757797c", - "end_line": 8687, + "end_line": 8674, "hard_limit": 120, "kind": "test_function", "observed_lines": 151, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8537 - }, - { - "capability": "unassigned_legacy_auth", - "content_sha256": "a45270573154ce2f298221169a3d55530059598bc976263feafc349c77c6ea46", - "end_line": 6339, - "hard_limit": 120, - "kind": "test_function", - "observed_lines": 121, - "path": "backend/tests/test_authorization.py", - "qualified_symbol": "test_pre_submit_materializer_adapter_binds_every_fact_and_service", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6219 + "start_line": 8524 }, { "capability": "unassigned_legacy_auth", "content_sha256": "c5d6d0d480ced964354915614f15202c0159bfbb281658da0d44186dffd17848", - "end_line": 7482, + "end_line": 7469, "hard_limit": 120, "kind": "test_function", "observed_lines": 142, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_actor_authority_crossed_mutations_complete_in_both_orders", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7341 + "start_line": 7328 }, { "capability": "unassigned_legacy_auth", "content_sha256": "ebb76e63671195aa4d806ac602bfe58cb22bf82d00c8a70ab186785f3acd7f9b", - "end_line": 7823, + "end_line": 7810, "hard_limit": 120, "kind": "test_function", "observed_lines": 336, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7488 + "start_line": 7475 }, { "capability": "unassigned_legacy_auth", "content_sha256": "9ea4fc0ddbab4c7262a43bc3f498ee1afea3318e9a0b6c93c87763f9f22aae9c", - "end_line": 7313, + "end_line": 7300, "hard_limit": 120, "kind": "test_function", "observed_lines": 518, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_postgresql_failure_and_cancellation_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6796 + "start_line": 6783 }, { "capability": "unassigned_legacy_auth", @@ -687,38 +675,38 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "105667302ed6e8f2fd16ea7e95d642e72e41514673e1152503536e0520f9c362", - "end_line": 10961, + "end_line": 10948, "hard_limit": 120, "kind": "test_function", "observed_lines": 204, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_read_permissions_have_postgresql_role_scope_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10758 + "start_line": 10745 }, { "capability": "unassigned_legacy_auth", "content_sha256": "756b7f99f9a743284934b4d85ce263617710d9b8119792ccb4526a1de04070a1", - "end_line": 12463, + "end_line": 12450, "hard_limit": 120, "kind": "test_function", "observed_lines": 233, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_and_all_operation_mappings_commit_one_linked_pair", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12231 + "start_line": 12218 }, { "capability": "unassigned_legacy_auth", "content_sha256": "2e1d7db74ddb955b90a0ee12e4fb72b651a0f85d2746c76e09079c05b0b85252", - "end_line": 13434, + "end_line": 13421, "hard_limit": 120, "kind": "test_function", "observed_lines": 681, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_issue_postgresql_prep_binds_target_role_and_scope", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12754 + "start_line": 12741 }, { "capability": "unassigned_legacy_auth", @@ -747,26 +735,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "05621e885ed2f88d0ba1a072c1f263fc923939f7ce755a514e9872112aa830a1", - "end_line": 11980, + "end_line": 11967, "hard_limit": 120, "kind": "test_function", "observed_lines": 163, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_service_actor_replay_fails_closed_on_committed_state_drift", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11818 + "start_line": 11805 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "1ef3d6ee06ed0999a5752b168ba34b0a2b847793b386bc20fe72ecc335234bc5", - "end_line": 1132, + "content_sha256": "be162594febdd56816d1a6a947b3d120ca35538f3fae5864ff0cac7a168493c3", + "end_line": 1134, "hard_limit": 120, "kind": "test_function", - "observed_lines": 701, + "observed_lines": 699, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": "test_effective_evidence_workflow_persists_once_and_replays_exactly", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 432 + "start_line": 436 }, { "capability": "unassigned_legacy_auth", @@ -1035,14 +1023,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "1a0a9f3e2be6965e29f76aa74272ccfa2fe4b99e4e0c3bde5ec8ba2c374b369b", - "end_line": 11294, + "end_line": 11281, "hard_limit": 100, "kind": "test_helper", "observed_lines": 169, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11126 + "start_line": 11113 }, { "capability": "unassigned_legacy_auth", diff --git a/.agent-loop/policies/architecture-boundaries.md b/.agent-loop/policies/architecture-boundaries.md index 8c7e4f157..7e6f10be6 100644 --- a/.agent-loop/policies/architecture-boundaries.md +++ b/.agent-loop/policies/architecture-boundaries.md @@ -10,8 +10,9 @@ Cross-module runtime imports target only `app.modules..api`. A public API may expose immutable facts, commands/results, stable errors, opaque capability protocols, and ports. It may not expose ORM models, repositories, routers, database sessions, concrete services, kernels, registries, or other -private implementation surfaces. Concrete implementations meet only in the -application composition root. +private implementation surfaces. Concrete implementations meet only in an +explicit composition root: the application root or the exact owner adapter +root `backend/app/adapters//__init__.py`. Application-level paths are explicit rather than invisible exceptions: @@ -20,15 +21,22 @@ Application-level paths are explicit rather than invisible exceptions: public ports; - `backend/app/api/**` and `backend/app/workers/**` are delivery/composition entry code and must consume typed module APIs; -- `backend/app/adapters/**` implements infrastructure capabilities and is wired - at composition boundaries; +- the exact `backend/app/adapters//__init__.py` file is that owner's + adapter composition root and may import its own private implementation solely + to construct typed public ports; this exception does not apply to nested + adapter files, cross-owner private imports, product services, or delivery + code; +- other `backend/app/adapters/**` files implement infrastructure capabilities + and must consume module public APIs; their existing private imports remain + frozen debt; - `backend/app/interfaces/**` is legacy shared-contract debt, not a permanent public-contract namespace; - `backend/app/db/models.py` is the sole metadata-discovery path and may import only module model declarations for SQLAlchemy registration. It gains no runtime service, repository, command, or authorization capability. -All current private imports in the first four surfaces are frozen exact debt. +Except for the exact same-owner adapter-root rule above, all current private +imports in the first four surfaces are frozen exact debt. Every application path is scanned, including paths outside `modules/`. The one-time bootstrap is permitted only when the protected base contains neither registry nor ledger and the installing change touches no diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index c64cbebf7..853fb8615 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -48,6 +48,10 @@ "group": "shared", "target": "backend/app/api/router.py" }, + { + "group": "lifecycle", + "target": "backend/app/api/routes/artifact_submissions.py" + }, { "group": "auth", "target": "backend/app/api/routes/auth.py" @@ -168,6 +172,10 @@ "group": "shared", "target": "backend/app/modules/api_controls/service.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/artifacts/api/submission_preparation.py" + }, { "group": "artifacts", "target": "backend/app/modules/artifacts/authorization.py" @@ -801,7 +809,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "ffaf4c71c39ad59d241f5526862800d870941a300d2f5810bb0ad33e30cb3b66", + "authority_digest": "ac50305ca09484ea683478e5a703056402892f61f0368b1a5ce5dee2e3d5ab15", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/module-boundaries/private-edge-debt.v1.json b/.ci/module-boundaries/private-edge-debt.v1.json index a30ef18dc..1652cc348 100644 --- a/.ci/module-boundaries/private-edge-debt.v1.json +++ b/.ci/module-boundaries/private-edge-debt.v1.json @@ -7,54 +7,6 @@ "imported_private_path": "app.modules.actors.service_identities", "repair_owner": "WS-ARCH-001-03" }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.authorization", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.preparation", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.schemas", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.service", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_admission", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_archive", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_authorization", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/adapters/artifacts/__init__.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_materialization", - "repair_owner": "WS-ARCH-001-04" - }, { "source_file": "backend/app/adapters/artifacts/internal_workers.py", "target_module": "actors", @@ -235,36 +187,6 @@ "imported_private_path": "app.modules.projects.service", "repair_owner": "WS-ARCH-001-03" }, - { - "source_file": "backend/app/interfaces/artifact_operations.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.sources", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/interfaces/artifact_operations.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_archive", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/interfaces/artifact_operations.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_manifest", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/interfaces/artifact_operations.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.effective_plan", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/interfaces/artifact_operations.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.pre_submit_execution", - "repair_owner": "WS-ARCH-001-04" - }, { "source_file": "backend/app/interfaces/artifacts.py", "target_module": "artifacts", @@ -373,24 +295,6 @@ "imported_private_path": "app.modules.tasks.models", "repair_owner": "WS-ARCH-001-03" }, - { - "source_file": "backend/app/modules/artifacts/pre_submit_evidence.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.effective_plan", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/artifacts/pre_submit_evidence.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.pre_submit_execution", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/artifacts/pre_submit_evidence.py", - "target_module": "tasks", - "imported_private_path": "app.modules.tasks.pre_submit_context", - "repair_owner": "WS-ARCH-001-03" - }, { "source_file": "backend/app/modules/artifacts/repository.py", "target_module": "checkers", @@ -445,36 +349,6 @@ "imported_private_path": "app.modules.tasks.pre_submit_context", "repair_owner": "WS-ARCH-001-03" }, - { - "source_file": "backend/app/modules/artifacts/submission_admission.py", - "target_module": "tasks", - "imported_private_path": "app.modules.tasks.pre_submit_context", - "repair_owner": "WS-ARCH-001-03" - }, - { - "source_file": "backend/app/modules/artifacts/submission_materialization.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.service_identities", - "repair_owner": "WS-ARCH-001-03" - }, - { - "source_file": "backend/app/modules/artifacts/submission_materialization.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.catalogue", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/artifacts/submission_materialization.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.effective_plan", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/artifacts/submission_materialization.py", - "target_module": "checkers", - "imported_private_path": "app.modules.checkers.pre_submit_execution", - "repair_owner": "WS-ARCH-001-04" - }, { "source_file": "backend/app/modules/audit/repository.py", "target_module": "tasks", @@ -721,24 +595,6 @@ "imported_private_path": "app.modules.actors.service", "repair_owner": "WS-ARCH-001-03" }, - { - "source_file": "backend/app/modules/tasks/router.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.authorization", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/tasks/router.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.schemas", - "repair_owner": "WS-ARCH-001-04" - }, - { - "source_file": "backend/app/modules/tasks/router.py", - "target_module": "artifacts", - "imported_private_path": "app.modules.artifacts.submission_admission", - "repair_owner": "WS-ARCH-001-04" - }, { "source_file": "backend/app/modules/tasks/service.py", "target_module": "actors", diff --git a/backend/app/adapters/README.md b/backend/app/adapters/README.md index 0f49d6fe7..e5fa7e7d7 100644 --- a/backend/app/adapters/README.md +++ b/backend/app/adapters/README.md @@ -2,3 +2,7 @@ Adapters implement interfaces for concrete providers such as Flow auth, local storage, object storage, and checker runners. +Each exact `backend/app/adapters//__init__.py` is an owner composition +root. It may import that owner's private implementation only to construct typed +public ports. Nested adapter files must consume module public APIs, and no +adapter may import another module's private implementation. diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index 02b6ef0ec..1de501e0b 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -11,8 +11,13 @@ from app.core.config import Settings from app.core.api_controls import request_ids +from app.api.deps.authorization import get_authorization_actor_identity +from app.adapters.checkers import PreSubmitCheckerExecutionAdapter +from app.adapters.projects import project_locked_policy_context_port +from app.adapters.tasks import task_submission_context_port from app.db.session import get_db_session from app.interfaces.artifact_operations import GuideArtifactIngestCommand +from app.modules.artifacts.api import SubmissionBundlePreparationCommand from app.interfaces.artifacts import ( ARTIFACT_STORE_CAPABILITY_KEY, ArtifactConfigurationError, @@ -30,7 +35,6 @@ from app.modules.artifacts.submission_authorization import ( SubmissionBundlePreparationAuthorization, ) -from app.modules.artifacts.submission_admission import SubmissionBundlePreparationCommand from app.modules.artifacts.schemas import ( ArtifactInternalAuthority, ) @@ -40,6 +44,15 @@ get_guide_artifact_prepared_authorization, ) from app.modules.actors.service_identities import ServiceIdentity +from app.modules.authorization.api import ActorIdentityFacts +async def get_submission_bundle_preparation_actor( + actor: Annotated[ + ActorIdentityFacts, + Depends(get_authorization_actor_identity), + ], +) -> ActorIdentityFacts: + """Return dependency-safe AUTH facts to the route-facing ART command.""" + return actor def create_artifact_store_bootstrap(settings: Settings) -> ArtifactStoreBootstrap: @@ -249,19 +262,29 @@ def get_submission_bundle_preparation_command( PreparedSubmissionBundlePreparationCommand, SubmissionBundlePreparationRuntime, ) - settings = request.app.state.settings request_id, correlation_id = (UUID(value) for value in request_ids(request)) + task_contexts = task_submission_context_port(session) + project_contexts = project_locked_policy_context_port(session) @asynccontextmanager async def runtime(): bootstrap = create_artifact_store_bootstrap(settings) - manager = create_artifact_scratch_manager(settings) - materialization_authority = PreparedPreSubmitMaterializationAuthorization( - session, - request_id=request_id, - correlation_id=correlation_id, - ) + try: + manager = create_artifact_scratch_manager(settings) + except BaseException: + bootstrap.close() + raise + try: + materialization_authority = PreparedPreSubmitMaterializationAuthorization( + session, + request_id=request_id, + correlation_id=correlation_id, + ) + except BaseException: + manager.close() + bootstrap.close() + raise try: namespace = artifact_storage_namespace_spec(settings, bootstrap) store = bootstrap.initialize_after_namespace_claim( @@ -273,6 +296,11 @@ async def runtime(): ) preparation = ArtifactPreparationService(manager) catalogue = request.app.state.pre_submission_checker_catalogue + inspector = SubmissionArchiveInspector(submission_archive_limits(settings)) + checker_execution = PreSubmitCheckerExecutionAdapter( + catalogue=catalogue, + archive_inspector=inspector, + ) storage_schemes = {"local": "local", "s3_compatible": "s3"} try: storage_scheme = storage_schemes[settings.artifact_store_backend] @@ -281,8 +309,7 @@ async def runtime(): materialization = PreparedBundleMaterializationService( authorization=materialization_authority, preparation=preparation, - archive_inspector=SubmissionArchiveInspector(submission_archive_limits(settings)), - catalogue=catalogue, + checker_execution=checker_execution, storage_scheme=storage_scheme, ) admission = ArtifactAdmissionService(session, settings, namespace) @@ -295,12 +322,15 @@ async def runtime(): ) yield SubmissionBundlePreparationRuntime( preparation=preparation, - inspector=SubmissionArchiveInspector(submission_archive_limits(settings)), + inspector=inspector, catalogue=catalogue, materialization=materialization, evidence=PreparedBundlePreSubmitEvidenceService( session=session, materialization=materialization, + preparation_authorization=authority, + task_contexts=task_contexts, + project_contexts=project_contexts, ), durable_put=SubmissionBundleDurablePutService( session=session, @@ -317,6 +347,8 @@ async def runtime(): return PreparedSubmissionBundlePreparationCommand( session=session, authority=authority, + task_contexts=task_contexts, + project_contexts=project_contexts, runtime_factory=runtime, ) diff --git a/backend/app/adapters/checkers/__init__.py b/backend/app/adapters/checkers/__init__.py new file mode 100644 index 000000000..f9b650e0b --- /dev/null +++ b/backend/app/adapters/checkers/__init__.py @@ -0,0 +1,78 @@ +"""CHECKER-owned composition adapters.""" + +import asyncio +from typing import Any, BinaryIO, Protocol +from pathlib import Path + +from app.modules.checkers.api import ( + PreSubmissionExecutionFacts, + PreSubmissionInfrastructureUnavailableError, +) +from app.modules.checkers.catalogue import PreSubmissionCheckerCatalogue +from app.modules.checkers.pre_submit_execution import ( + DefaultPreSubmissionExecutionError, + DefaultPreSubmissionExecutionInput, + EffectivePreSubmissionProcessor, +) + + +class _ExecutionRequest(Protocol): + plan: Any + commitment: Any + inspection: Any + manifest: Any + change_gate: Any + packet: Any + prepared_generation_id: Any + storage_scheme: str + + +class _PublicFactsCheckerProcessor: + """Project the private CHECKER processor result to public facts.""" + + def __init__(self, processor: EffectivePreSubmissionProcessor) -> None: + self._processor = processor + + def abort(self) -> None: + self._processor.abort() + + async def process( + self, reader: BinaryIO, workspace: Path + ) -> PreSubmissionExecutionFacts: + try: + result = await asyncio.to_thread( + self._processor.process_blocking, reader, workspace + ) + return result.bounded_facts() + except DefaultPreSubmissionExecutionError as exc: + raise PreSubmissionInfrastructureUnavailableError(str(exc)) from exc + + +class PreSubmitCheckerExecutionAdapter: + """Build the private CHECKER processor behind dependency-safe facts.""" + + def __init__(self, *, catalogue: PreSubmissionCheckerCatalogue, archive_inspector: Any): + self._catalogue = catalogue + self._archive_inspector = archive_inspector + + @property + def catalogue_manifest_sha256(self) -> str: + return self._catalogue.manifest_sha256 + + def build(self, request: _ExecutionRequest) -> _PublicFactsCheckerProcessor: + return _PublicFactsCheckerProcessor( + EffectivePreSubmissionProcessor( + archive_inspector=self._archive_inspector, + catalogue=self._catalogue, + execution_input=DefaultPreSubmissionExecutionInput( + plan=request.plan, + commitment=request.commitment, + inspection=request.inspection, + manifest=request.manifest, + change_gate=request.change_gate, + packet=request.packet, + prepared_generation_id=request.prepared_generation_id, + storage_scheme=request.storage_scheme, + ), + ) + ) diff --git a/backend/app/adapters/projects/__init__.py b/backend/app/adapters/projects/__init__.py new file mode 100644 index 000000000..27ace0515 --- /dev/null +++ b/backend/app/adapters/projects/__init__.py @@ -0,0 +1,13 @@ +"""PROJECT-owned composition adapters.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.projects.api import ProjectLockedPolicyContextPort +from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository + + +def project_locked_policy_context_port( + session: AsyncSession, +) -> ProjectLockedPolicyContextPort: + """Bind the public PROJECT locked-policy port to its repository.""" + return ProjectLockedPolicyRepository(session) diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py new file mode 100644 index 000000000..b78d1a5eb --- /dev/null +++ b/backend/app/adapters/tasks/__init__.py @@ -0,0 +1,11 @@ +"""TASK-owned composition adapters.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.tasks.api import TaskSubmissionContextPort +from app.modules.tasks.repository import TaskRepository + + +def task_submission_context_port(session: AsyncSession) -> TaskSubmissionContextPort: + """Bind the public TASK submission-context port to its repository.""" + return TaskRepository(session) diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index cf78d2c0e..e8394d408 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -29,6 +29,7 @@ ) from app.modules.api_controls.service import FIRST_ACCESS_SCOPE, RateControlService from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.api import ActorIdentityFacts, ActorKind as PublicActorKind from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( @@ -85,6 +86,23 @@ async def get_authorization_actor( return await resolve_authorization_actor(request, result, session, rate_control) +async def get_authorization_actor_identity( + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], +) -> ActorIdentityFacts: + """Project the resolved request actor into the dependency-safe AUTH facts.""" + service_identity = resolved.profile.service_identity + return ActorIdentityFacts( + actor_profile_id=UUID(resolved.profile.id), + identity_link_id=UUID(resolved.identity_link.id), + actor_kind=( + PublicActorKind.SERVICE + if resolved.profile.actor_kind == ActorKind.SERVICE + else PublicActorKind.HUMAN + ), + service_identity=service_identity, + ) + + async def resolve_authorization_actor( request: Request, result: AuthVerificationResult, diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 92a61445c..b4f08564f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -7,6 +7,7 @@ from app.api.routes.auth import actors_router, router as auth_router from app.api.routes.health import router as health_router from app.modules.checkers.router import router as checkers_router +from app.api.routes.artifact_submissions import router as artifact_submission_router from app.modules.artifacts.router import router as artifacts_router from app.modules.authorization.router import router as authorization_router from app.modules.projects.router import router as projects_router @@ -28,3 +29,4 @@ api_router.include_router(tasks_router, prefix="/api/v1") api_router.include_router(checkers_router, prefix="/api/v1") api_router.include_router(artifacts_router, prefix="/api/v1") +api_router.include_router(artifact_submission_router, prefix="/api/v1") diff --git a/backend/app/api/routes/artifact_submissions.py b/backend/app/api/routes/artifact_submissions.py new file mode 100644 index 000000000..76f79508a --- /dev/null +++ b/backend/app/api/routes/artifact_submissions.py @@ -0,0 +1,99 @@ +"""Hidden delivery surface for ART-owned contributor bundle preparation.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status +from pydantic import BaseModel, ConfigDict + +from app.adapters.artifacts import ( + get_submission_bundle_preparation_actor, + get_submission_bundle_preparation_command, +) +from app.core.api_controls import request_ids +from app.modules.artifacts.api import ( + SubmissionBundlePreparationCommand, + SubmissionBundlePreparationRejected, + SubmissionBundlePreparationRequest, + SubmissionBundlePreparationStatus, + SubmissionBundlePreparationUnavailable, +) +from app.modules.authorization.api import ActorIdentityFacts + +router = APIRouter(tags=["tasks"]) + + +class SubmissionBundlePreparationResponse(BaseModel): + """Bounded hidden operation state without provider or scratch coordinates.""" + + model_config = ConfigDict(extra="forbid", from_attributes=True) + + put_attempt_id: UUID + admission_id: UUID | None + submission_bundle_preparation_status: SubmissionBundlePreparationStatus + replayed: bool + + +@router.post( + "/tasks/{task_id}/submission-bundle-preparations", + response_model=SubmissionBundlePreparationResponse, + status_code=status.HTTP_202_ACCEPTED, + include_in_schema=False, +) +async def prepare_submission_bundle( + task_id: str, + request: Request, + actor: Annotated[ActorIdentityFacts, Depends(get_submission_bundle_preparation_actor)], + command: Annotated[ + SubmissionBundlePreparationCommand, + Depends(get_submission_bundle_preparation_command), + ], + assignment_id: Annotated[str | None, Header(alias="X-Task-Assignment-Id")] = None, + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + summary: Annotated[str | None, Header(alias="X-Submission-Summary")] = None, + contributor_attestation: Annotated[ + str | None, Header(alias="X-Contributor-Attestation") + ] = None, + predecessor_submission_id: Annotated[ + str | None, Header(alias="X-Predecessor-Submission-Id") + ] = None, +) -> SubmissionBundlePreparationResponse: + """Run the hidden continuous ZIP preparation surface; AUTH remains fail closed.""" + if None in (assignment_id, idempotency_key, summary, contributor_attestation): + raise HTTPException(status_code=404, detail="Task not found") + assert assignment_id is not None and idempotency_key is not None + assert summary is not None and contributor_attestation is not None + try: + identifiers = ( + UUID(task_id), + UUID(assignment_id), + UUID(idempotency_key), + UUID(predecessor_submission_id) if predecessor_submission_id else None, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail="Task not found") from exc + try: + request_id, correlation_id = (UUID(value) for value in request_ids(request)) + result = await command.prepare( + SubmissionBundlePreparationRequest( + actor=actor, + request_id=request_id, + correlation_id=correlation_id, + task_id=identifiers[0], + assignment_id=identifiers[1], + predecessor_submission_id=identifiers[3], + idempotency_key=identifiers[2], + summary=summary, + contributor_attestation=contributor_attestation, + media_type=request.headers.get("content-type", ""), + byte_source=request.stream(), + ) + ) + except SubmissionBundlePreparationUnavailable as exc: + raise HTTPException(status_code=404, detail="Task not found") from exc + except SubmissionBundlePreparationRejected as exc: + code = str(exc) + if code == "submission_bundle_preparation_context_changed": + raise HTTPException(status_code=409, detail=code) from exc + raise HTTPException(status_code=422, detail=code) from exc + return SubmissionBundlePreparationResponse.model_validate(result, from_attributes=True) diff --git a/backend/app/interfaces/artifact_operations.py b/backend/app/interfaces/artifact_operations.py index eec9f243c..7abc3f0e5 100644 --- a/backend/app/interfaces/artifact_operations.py +++ b/backend/app/interfaces/artifact_operations.py @@ -7,19 +7,8 @@ from typing import Literal, Protocol from uuid import UUID -from app.modules.artifacts.sources import PreparedArtifact -from app.modules.artifacts.submission_archive import SubmissionArchiveInspectionResult -from app.modules.artifacts.submission_manifest import ( - SubmissionChangeGateResult, - SubmissionManifest, -) from app.modules.authorization.prepared import PreparedAuthorizationHandle from app.modules.authorization.runtime import AuthorizationContext -from app.modules.checkers.effective_plan import EffectivePreSubmissionExecutionPlan -from app.modules.checkers.pre_submit_execution import ( - PreSubmissionExecutionResult, - SubmissionPacketView, -) __all__ = ( "ArtifactAuditResourceType", @@ -47,9 +36,6 @@ "GuideSufficiencyMaterialUnavailable", "GuideSufficiencySourceItem", "GuideSufficiencyExtractionProvenance", - "PreparedBundleMaterializationRequest", - "SubmissionBundlePreparationPort", - "SubmissionBundlePreparationRequest", "SubmissionBindingRequest", ) @@ -255,38 +241,6 @@ class CheckerOutputBindingRequest: verified_content_ids: tuple[UUID, ...] -@dataclass(frozen=True, slots=True) -class SubmissionBundlePreparationRequest: - """One authorized continuous contributor ZIP request without local handles.""" - - authorization_context: AuthorizationContext - task_id: UUID - assignment_id: UUID - predecessor_submission_id: UUID | None - idempotency_key: UUID - summary: str - contributor_attestation: str - media_type: str - byte_source: AsyncIterable[bytes] - - -@dataclass(frozen=True, slots=True) -class PreparedBundleMaterializationRequest: - """Process-local prepared bytes and exact 04A/04B1 execution facts.""" - - prepared_authorization: PreparedAuthorizationHandle - task_id: UUID - assignment_id: UUID - submission_artifact_policy_id: UUID - checker_policy_id: UUID - prepared_artifact: PreparedArtifact - effective_plan: EffectivePreSubmissionExecutionPlan - inspection: SubmissionArchiveInspectionResult - manifest: SubmissionManifest - change_gate: SubmissionChangeGateResult - packet: SubmissionPacketView - - @dataclass(frozen=True, slots=True) class BindingMaterializationRequest: """Immutable bindings selected by exact execution context.""" @@ -348,13 +302,6 @@ async def ingest( """Prepare authority before delegating to durable byte ingestion.""" -class SubmissionBundlePreparationPort(Protocol): - """Prepare one continuous contributor bundle without upload sessions.""" - - async def prepare(self, request: SubmissionBundlePreparationRequest) -> object: - """Prepare one authorized outer ZIP in bounded private scratch.""" - - class ArtifactBindingPort(Protocol): """Create exact action-bound bindings from verified content.""" @@ -373,12 +320,6 @@ async def bind_checker_output(self, request: CheckerOutputBindingRequest) -> obj class ArtifactMaterializationPort(Protocol): """Materialize only canonical immutable source forms.""" - async def materialize_prepared_bundle( - self, - request: PreparedBundleMaterializationRequest, - ) -> PreSubmissionExecutionResult: - """Materialize one process-local prepared bundle generation.""" - async def materialize_guide_source( self, request: GuideSourceMaterializationRequest, diff --git a/backend/app/modules/artifacts/api/__init__.py b/backend/app/modules/artifacts/api/__init__.py new file mode 100644 index 000000000..983cd5011 --- /dev/null +++ b/backend/app/modules/artifacts/api/__init__.py @@ -0,0 +1,19 @@ +"""Dependency-safe public API for the ARTIFACTS business module.""" + +from app.modules.artifacts.api.submission_preparation import ( + SubmissionBundlePreparationCommand, + SubmissionBundlePreparationRejected, + SubmissionBundlePreparationRequest, + SubmissionBundlePreparationResult, + SubmissionBundlePreparationStatus, + SubmissionBundlePreparationUnavailable, +) + +__all__ = ( + "SubmissionBundlePreparationCommand", + "SubmissionBundlePreparationRejected", + "SubmissionBundlePreparationRequest", + "SubmissionBundlePreparationResult", + "SubmissionBundlePreparationStatus", + "SubmissionBundlePreparationUnavailable", +) diff --git a/backend/app/modules/artifacts/api/submission_preparation.py b/backend/app/modules/artifacts/api/submission_preparation.py new file mode 100644 index 000000000..8d0ddddda --- /dev/null +++ b/backend/app/modules/artifacts/api/submission_preparation.py @@ -0,0 +1,73 @@ +"""Public ART contracts for hidden contributor bundle preparation.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol +from uuid import UUID + +from app.modules.authorization.api import ActorIdentityFacts + + +class SubmissionBundlePreparationRejected(RuntimeError): + """Reject preparation with one stable ART-owned failure code.""" + + +class SubmissionBundlePreparationUnavailable(RuntimeError): + """Conceal unavailable or denied preparation authority.""" + + +class SubmissionBundlePreparationStatus(StrEnum): + """Closed public state vocabulary for one ART preparation operation.""" + + PREPARED = "prepared" + PUT_IN_FLIGHT = "put_in_flight" + OBJECT_CONFIRMED = "object_confirmed" + STORED_PENDING_VERIFICATION = "stored_pending_verification" + OBSERVED_CONFIRMED = "observed_confirmed" + ACKNOWLEDGEMENT_UNKNOWN = "acknowledgement_unknown" + PROVIDER_UNAVAILABLE = "provider_unavailable" + CONFLICT = "conflict" + INTEGRITY_MISMATCH = "integrity_mismatch" + MISSING = "missing" + ABSENT_REPLAY_REQUIRED = "absent_replay_required" + STALE = "stale" + READY = "ready" + + +@dataclass(frozen=True, slots=True) +class SubmissionBundlePreparationRequest: + """One continuous contributor ZIP request with server-owned actor facts.""" + + actor: ActorIdentityFacts + request_id: UUID + correlation_id: UUID + task_id: UUID + assignment_id: UUID + predecessor_submission_id: UUID | None + idempotency_key: UUID + summary: str + contributor_attestation: str + media_type: str + byte_source: AsyncIterable[bytes] + + +@dataclass(frozen=True, slots=True) +class SubmissionBundlePreparationResult: + """Bounded preparation result without custody or provider coordinates.""" + + put_attempt_id: UUID + admission_id: UUID | None + submission_bundle_preparation_status: SubmissionBundlePreparationStatus + replayed: bool + + +class SubmissionBundlePreparationCommand(Protocol): + """Prepare one hidden contributor bundle through the sole ART command.""" + + async def prepare( + self, request: SubmissionBundlePreparationRequest + ) -> SubmissionBundlePreparationResult: + """Run the continuous fail-closed preparation workflow.""" diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index 453e1a31e..7061a7021 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -540,17 +540,10 @@ async def prepare( async def consume( self, *, - service_identity: ServiceIdentity, - action_id: ActionId, - prepared_authorization: PreparedAuthorizationHandle, + prepared_authorization: object, facts: PreSubmitMaterializationAuthorityFacts, ) -> None: """Consume authority only for the exact materializer and final facts.""" - if ( - service_identity is not ServiceIdentity.ARTIFACT_MATERIALIZER - or action_id is not ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE - ): - raise ArtifactAuthorityDeniedError("pre-submit materialization authority is invalid") await self._delegate.consume( prepared_authorization=prepared_authorization, facts=facts, diff --git a/backend/app/modules/artifacts/pre_submit_evidence.py b/backend/app/modules/artifacts/pre_submit_evidence.py index 1b0b0d948..9c507ee31 100644 --- a/backend/app/modules/artifacts/pre_submit_evidence.py +++ b/backend/app/modules/artifacts/pre_submit_evidence.py @@ -13,13 +13,74 @@ from app.core.hashing import canonical_json_hash from app.modules.artifacts.models import PreSubmitEvidenceResult, PreSubmitEvidenceSet from app.modules.artifacts.sources import ArtifactCommitment -from app.modules.checkers.pre_submit_execution import ( +from app.modules.checkers.api import ( ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, - PreSubmissionEntryResult, - PreSubmissionExecutionResult, - validate_pre_submission_execution_result, + EffectivePreSubmissionExecutionPlan, + PreSubmissionExecutionEntryFacts, + PreSubmissionExecutionFacts, + PreSubmissionInfrastructureUnavailableError, + validate_pre_submission_execution_facts, ) -from app.modules.checkers.effective_plan import EffectivePreSubmissionExecutionPlan +from app.modules.projects.api import ( + ProjectLockedPolicyContextPort, + ProjectLockedPolicyContextRequest, + ProjectLockedPolicyContextUnavailable, +) +from app.modules.tasks.api import ( + TaskSubmissionContextFacts, + TaskSubmissionContextPort, + TaskSubmissionContextRequest, + TaskSubmissionContextUnavailable, +) + +def validate_predecessor_lineage( + task_context: TaskSubmissionContextFacts, + *, + predecessor_submission_id: UUID | None, + predecessor_submission_version: int | None, +) -> None: + """Require the post-byte predecessor to equal the initially checked version.""" + predecessor = task_context.predecessor + if ( + (predecessor is None) != (predecessor_submission_id is None) + or predecessor is not None + and ( + predecessor.submission_id != predecessor_submission_id + or predecessor.version != predecessor_submission_version + ) + ): + raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") + + +@dataclass(frozen=True, slots=True) +class PreSubmitExecutionCustody: + """ART-owned custody observed around one CHECKER execution.""" + + prepared_generation_id: UUID + archive_sha256: str + archive_byte_count: int + semantic_manifest_sha256: str + storage_scheme: str + + +@dataclass(frozen=True, slots=True) +class PreSubmitExecutionResult: + """ART custody combined with dependency-safe CHECKER result facts.""" + + custody: PreSubmitExecutionCustody + checker_facts: PreSubmissionExecutionFacts + + @property + def plan_sha256(self) -> str: + return self.checker_facts.plan_sha256 + + @property + def eligible(self) -> bool: + return self.checker_facts.eligible + + @property + def entries(self) -> tuple[PreSubmissionExecutionEntryFacts, ...]: + return self.checker_facts.entries class PreSubmitEvidenceConflict(RuntimeError): @@ -140,12 +201,13 @@ class PreSubmitEvidencePersistenceRequest: task_id: UUID assignment_id: UUID predecessor_submission_id: UUID | None + expected_predecessor_submission_version: int | None prepared_generation_id: UUID archive_sha256: str archive_byte_count: int semantic_manifest_sha256: str plan: EffectivePreSubmissionExecutionPlan - execution: PreSubmissionExecutionResult + execution: PreSubmitExecutionResult @dataclass(frozen=True, slots=True) @@ -164,7 +226,7 @@ def pre_submit_failure_audit_payload( task_id: UUID, prepared_generation_id: UUID, evidence: PersistedPreSubmitEvidence, - execution: PreSubmissionExecutionResult, + execution: PreSubmitExecutionResult, catalogue_id: str, catalogue_version: str, ) -> dict[str, object]: @@ -178,20 +240,20 @@ def pre_submit_failure_audit_payload( for result in execution.entries: result_outcomes.append( { - "definition_id": result.definition.definition_id, - "definition_version": result.definition.definition_version, - "status": result.status.value, + "definition_id": result.definition_id, + "definition_version": result.definition_version, + "status": result.checker_execution_status, "message_code": result.message_code, } ) - if result.status.value == "failed": + if result.checker_execution_status == "failed": counts["failed"] += 1 categories.add(result.classification) if result.failure_code is not None: failure_codes.add(result.failure_code) - elif result.status.value == "warning": + elif result.checker_execution_status == "warning": counts["warning"] += 1 - elif result.status.value == "dependency_not_run": + elif result.checker_execution_status == "dependency_not_run": counts["not_run"] += 1 return { "event_type": "pre_submission_check_failed", @@ -223,6 +285,42 @@ def semantic_manifest_identity(semantic_manifest_sha256: str) -> UUID: ) +def _validate_execution( + plan: EffectivePreSubmissionExecutionPlan, + execution: PreSubmitExecutionResult, +) -> None: + """Reject custody or CHECKER facts not bound to the exact immutable plan.""" + custody = execution.custody + try: + ArtifactCommitment.validate_sha256(custody.archive_sha256) + ArtifactCommitment.validate_sha256(custody.semantic_manifest_sha256) + except ValueError as exc: + raise PreSubmitEvidenceConflict("pre_submission_result_context_invalid") from exc + if ( + type(custody.prepared_generation_id) is not UUID + or type(custody.archive_byte_count) is not int + or custody.archive_byte_count < 0 + or custody.storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES + or type(execution.eligible) is not bool + or execution.plan_sha256 != plan.plan_sha256 + or len(execution.entries) != len(plan.entries) + ): + raise PreSubmitEvidenceConflict("pre_submission_result_context_invalid") + try: + validate_pre_submission_execution_facts( + plan, + PreSubmissionExecutionFacts( + plan_sha256=execution.plan_sha256, + eligible=execution.eligible, + entries=execution.entries, + ), + ) + except PreSubmissionInfrastructureUnavailableError as exc: + raise PreSubmitEvidenceConflict( + "pre_submission_result_context_invalid" + ) from exc + + _PRE_SUBMIT_PASS_CAPABILITY_SEAL = object() @@ -331,15 +429,23 @@ def _assert_live_prepared_custody( class _PreSubmitEvidenceRepository: """Insert or replay one immutable evidence set in the caller transaction.""" - def __init__(self, session: AsyncSession) -> None: + def __init__( + self, + session: AsyncSession, + *, + task_contexts: TaskSubmissionContextPort, + project_contexts: ProjectLockedPolicyContextPort, + ) -> None: self._session = session + self._task_contexts = task_contexts + self._project_contexts = project_contexts async def persist( self, *, context: PreSubmitEvidenceContext, plan: EffectivePreSubmissionExecutionPlan, - execution: PreSubmissionExecutionResult, + execution: PreSubmitExecutionResult, ) -> PersistedPreSubmitEvidence: """Persist exact results once; changed facts under the identity fail closed.""" transaction = self._session.sync_session.get_transaction() @@ -349,9 +455,9 @@ async def persist( or self._session.in_nested_transaction() ): raise RuntimeError("pre-submit evidence requires one root transaction") - validate_pre_submission_execution_result(plan, execution) + _validate_execution(plan, execution) operation_identity = context.operation_identity(effective_plan_sha256=execution.plan_sha256) - values = self._set_values(context, execution, operation_identity) + values = self._set_values(context, plan, execution, operation_identity) evidence_set_id = uuid4() inserted_id = await self._session.scalar( insert(PreSubmitEvidenceSet) @@ -379,8 +485,10 @@ async def persist( ).all() ) expected_results = tuple( - self._result_values(result_order, result) - for result_order, result in enumerate(execution.entries) + self._result_values(result_order, result, plan_entry.result_schema) + for result_order, (plan_entry, result) in enumerate( + zip(plan.entries, execution.entries, strict=True) + ) ) if len(persisted_results) != len(expected_results) or any( any(getattr(persisted, key) != value for key, value in expected.items()) @@ -392,12 +500,14 @@ async def persist( operation_identity=operation_identity, replayed=True, ) - for result_order, result in enumerate(execution.entries): + for result_order, (plan_entry, result) in enumerate( + zip(plan.entries, execution.entries, strict=True) + ): self._session.add( PreSubmitEvidenceResult( id=str(uuid4()), evidence_set_id=str(evidence_set_id), - **self._result_values(result_order, result), + **self._result_values(result_order, result, plan_entry.result_schema), ) ) await self._session.flush() @@ -408,30 +518,35 @@ async def persist( ) @staticmethod - def _result_values(result_order: int, result: PreSubmissionEntryResult) -> dict[str, object]: + def _result_values( + result_order: int, + result: PreSubmissionExecutionEntryFacts, + schema_version: str, + ) -> dict[str, object]: return { "result_order": result_order, - "schema_version": result.schema_version, - "dispatch_authority": result.definition.dispatch_authority, - "definition_id": result.definition.definition_id, - "definition_version": result.definition.definition_version, - "public_name": result.definition.public_name, - "source": result.definition.source, + "schema_version": schema_version, + "dispatch_authority": result.dispatch_authority, + "definition_id": result.definition_id, + "definition_version": result.definition_version, + "public_name": result.public_name, + "source": result.policy_source, "phase": result.phase, "classification": result.classification, "severity": result.severity, - "status": result.status.value, + "status": result.checker_execution_status, "failure_code": result.failure_code, "message_code": result.message_code, - "effective_plan_sha256": result.policy_trace.effective_plan_sha256, - "rule_instance_id": result.policy_trace.rule_instance_id, - "locked_policy_sha256": result.policy_trace.locked_policy_sha256, + "effective_plan_sha256": result.effective_plan_sha256, + "rule_instance_id": result.rule_instance_id, + "locked_policy_sha256": result.locked_policy_sha256, } @staticmethod def _set_values( context: PreSubmitEvidenceContext, - execution: PreSubmissionExecutionResult, + plan: EffectivePreSubmissionExecutionPlan, + execution: PreSubmitExecutionResult, operation_identity: str, ) -> dict[str, object]: values = { @@ -450,19 +565,27 @@ def _set_values( "schema_version": "pre_submit_evidence_result_manifest.v1", "entries": [ { - "schema_version": result.schema_version, - "definition": asdict(result.definition), - "policy_trace": asdict(result.policy_trace), + "schema_version": plan_entry.result_schema, + "dispatch_authority": result.dispatch_authority, + "definition_id": result.definition_id, + "definition_version": result.definition_version, + "public_name": result.public_name, + "policy_source": result.policy_source, + "effective_plan_sha256": result.effective_plan_sha256, + "rule_instance_id": result.rule_instance_id, + "locked_policy_sha256": result.locked_policy_sha256, "phase": result.phase, "order": result.order, "classification": result.classification, "severity": result.severity, - "status": result.status.value, + "status": result.checker_execution_status, "failure_code": result.failure_code, "message_code": result.message_code, "metadata": [list(item) for item in result.metadata], } - for result in execution.entries + for plan_entry, result in zip( + plan.entries, execution.entries, strict=True + ) ], } ), @@ -487,9 +610,21 @@ def _set_values( class PreSubmitEvidenceService: """Revalidate locked state and persist evidence in one caller transaction.""" - def __init__(self, session: AsyncSession) -> None: + def __init__( + self, + session: AsyncSession, + *, + task_contexts: TaskSubmissionContextPort, + project_contexts: ProjectLockedPolicyContextPort, + ) -> None: self._session = session - self._repository = _PreSubmitEvidenceRepository(session) + self._task_contexts = task_contexts + self._project_contexts = project_contexts + self._repository = _PreSubmitEvidenceRepository( + session, + task_contexts=task_contexts, + project_contexts=project_contexts, + ) self._live_pass_bindings: set[object] = set() def _claims_pass_binding(self, binding: object) -> bool: @@ -537,8 +672,6 @@ async def persist( self, request: PreSubmitEvidencePersistenceRequest ) -> PreSubmitEvidencePersistenceResult: """Persist only when the post-execution database lineage remains exact.""" - from app.modules.tasks.pre_submit_context import load_locked_pre_submit_context - transaction = self._session.sync_session.get_transaction() if ( transaction is None @@ -546,7 +679,7 @@ async def persist( or self._session.in_nested_transaction() ): raise RuntimeError("pre-submit evidence requires one root transaction") - validate_pre_submission_execution_result(request.plan, request.execution) + _validate_execution(request.plan, request.execution) custody = request.execution.custody if ( request.prepared_generation_id != custody.prepared_generation_id @@ -555,49 +688,93 @@ async def persist( or request.semantic_manifest_sha256 != custody.semantic_manifest_sha256 ): raise PreSubmitEvidenceConflict("pre_submit_execution_custody_changed") - locked = await load_locked_pre_submit_context( - self._session, - actor_profile_id=request.actor_profile_id, - identity_link_id=request.identity_link_id, - task_id=request.task_id, - assignment_id=request.assignment_id, + try: + task_context = await self._task_contexts.lock_submission_context( + TaskSubmissionContextRequest( + task_id=request.task_id, + assignment_id=request.assignment_id, + contributor_id=request.actor_profile_id, + predecessor_submission_id=request.predecessor_submission_id, + ) + ) + references = task_context.locked_project_context + project_context = await self._project_contexts.lock_locked_policy_context( + ProjectLockedPolicyContextRequest( + project_id=references.project_id, + guide_version=references.guide_version, + source_snapshot_id=references.source_snapshot_id, + source_snapshot_hash=references.source_snapshot_hash, + effective_policy_id=references.effective_policy_id, + effective_policy_hash=references.effective_policy_hash, + pre_submit_policy_id=references.pre_submit_policy_id, + pre_submit_policy_bundle_hash=references.pre_submit_policy_bundle_hash, + ) + ) + except (TaskSubmissionContextUnavailable, ProjectLockedPolicyContextUnavailable) as exc: + raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") from exc + validate_predecessor_lineage( + task_context, predecessor_submission_id=request.predecessor_submission_id, + predecessor_submission_version=( + request.expected_predecessor_submission_version + ), ) lineage = request.plan.lineage + guide_version = project_context.guide_version.removeprefix("v") + try: + numeric_guide_version = int(guide_version) + except ValueError as exc: + raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") from exc if ( - locked.project_id != lineage.project_id - or locked.guide_id != lineage.guide_id - or locked.guide_version != str(lineage.guide_version) - or locked.source_snapshot_id != lineage.source_snapshot_id - or locked.source_snapshot_sha256 != lineage.source_snapshot_hash - or locked.effective_policy_id != lineage.effective_policy_id - or locked.effective_policy_sha256 != lineage.effective_policy_hash - or locked.pre_submit_policy_id != lineage.pre_submit_policy_id - or locked.pre_submit_policy_sha256 != lineage.pre_submit_policy_bundle_hash + task_context.contributor_id != request.actor_profile_id + or project_context.project_id != lineage.project_id + or project_context.guide_id != lineage.guide_id + or numeric_guide_version != lineage.guide_version + or project_context.source_snapshot_id != lineage.source_snapshot_id + or project_context.source_snapshot_hash != lineage.source_snapshot_hash + or project_context.effective_policy_id != lineage.effective_policy_id + or project_context.effective_policy_hash != lineage.effective_policy_hash + or project_context.pre_submit_policy_id != lineage.pre_submit_policy_id + or project_context.pre_submit_policy_bundle_hash + != lineage.pre_submit_policy_bundle_hash ): raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") + locked_guide_sha256 = canonical_json_hash( + { + "domain": "workstream.locked_task_guide.v1", + "project_id": str(project_context.project_id), + "guide_id": str(project_context.guide_id), + "guide_version": project_context.guide_version, + "source_snapshot_id": str(project_context.source_snapshot_id), + "source_snapshot_sha256": project_context.source_snapshot_hash, + } + ) context = PreSubmitEvidenceContext( actor_profile_id=request.actor_profile_id, identity_link_id=request.identity_link_id, - project_id=locked.project_id, + project_id=project_context.project_id, task_id=request.task_id, assignment_id=request.assignment_id, predecessor_submission_id=request.predecessor_submission_id, - predecessor_submission_version=locked.predecessor_submission_version, + predecessor_submission_version=( + task_context.predecessor.version + if task_context.predecessor is not None + else None + ), prepared_generation_id=request.prepared_generation_id, archive_sha256=request.archive_sha256, archive_byte_count=request.archive_byte_count, semantic_manifest_id=semantic_manifest_identity(request.semantic_manifest_sha256), semantic_manifest_sha256=request.semantic_manifest_sha256, - guide_id=locked.guide_id, - guide_version=locked.guide_version, - source_snapshot_id=locked.source_snapshot_id, - source_snapshot_sha256=locked.source_snapshot_sha256, - locked_guide_sha256=locked.locked_guide_sha256, - effective_policy_id=locked.effective_policy_id, - locked_artifact_policy_sha256=locked.effective_policy_sha256, - pre_submit_policy_id=locked.pre_submit_policy_id, - locked_checker_policy_sha256=locked.pre_submit_policy_sha256, + guide_id=project_context.guide_id, + guide_version=project_context.guide_version, + source_snapshot_id=project_context.source_snapshot_id, + source_snapshot_sha256=project_context.source_snapshot_hash, + locked_guide_sha256=locked_guide_sha256, + effective_policy_id=project_context.effective_policy_id, + locked_artifact_policy_sha256=project_context.effective_policy_hash, + pre_submit_policy_id=project_context.pre_submit_policy_id, + locked_checker_policy_sha256=project_context.pre_submit_policy_bundle_hash, catalogue_id=request.plan.catalogue_id, catalogue_version=request.plan.catalogue_version, catalogue_manifest_sha256=request.plan.catalogue_manifest_sha256, @@ -626,7 +803,7 @@ async def persist( if request.execution.eligible else pre_submit_failure_audit_payload( actor_profile_id=request.actor_profile_id, - project_id=locked.project_id, + project_id=project_context.project_id, task_id=request.task_id, prepared_generation_id=request.prepared_generation_id, evidence=evidence, diff --git a/backend/app/modules/artifacts/preparation.py b/backend/app/modules/artifacts/preparation.py index 78150f30f..fe01dba06 100644 --- a/backend/app/modules/artifacts/preparation.py +++ b/backend/app/modules/artifacts/preparation.py @@ -56,10 +56,10 @@ class PreparedSubmissionProcessor(Protocol[_InspectionResultCo]): - """Blocking projection capability used only by the bounded async adapter.""" + """Async projection capability used by bounded submission preparation.""" - def process_blocking(self, reader: BinaryIO, workspace: Path) -> _InspectionResultCo: - """Return bounded results while executing outside the event loop.""" + async def process(self, reader: BinaryIO, workspace: Path) -> _InspectionResultCo: + """Return bounded results without blocking the event loop.""" def abort(self) -> None: """Deny checker access after cancellation or deadline expiry.""" @@ -1714,18 +1714,18 @@ async def _process_prepared_submission( if active is None or not active.handle_issued or active.stream_claimed: raise ArtifactScratchIntegrityError("prepared artifact source is unavailable") - def process_and_cleanup() -> _InspectionResult: + async def process_and_cleanup() -> _InspectionResult: with self._manager.extraction_workspace( reserved_bytes=reserved_bytes, maximum_entries=maximum_entries, ) as workspace: active.reader.seek(0) try: - return processor.process_blocking(active.reader, workspace) + return await processor.process(active.reader, workspace) finally: active.reader.seek(0) - operation = asyncio.create_task(self._run_io(process_and_cleanup)) + operation = asyncio.create_task(process_and_cleanup()) try: async with asyncio.timeout_at(active.deadline): return await asyncio.shield(operation) diff --git a/backend/app/modules/artifacts/submission_admission.py b/backend/app/modules/artifacts/submission_admission.py index 6c468d4ed..23291a64a 100644 --- a/backend/app/modules/artifacts/submission_admission.py +++ b/backend/app/modules/artifacts/submission_admission.py @@ -5,16 +5,18 @@ from collections.abc import Callable from contextlib import AbstractAsyncContextManager from dataclasses import dataclass -from typing import Protocol +import json from uuid import UUID, uuid4 from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.interfaces.artifact_operations import ( - PreparedBundleMaterializationRequest, - SubmissionBundlePreparationPort, +from app.modules.artifacts.api import ( + SubmissionBundlePreparationRejected, SubmissionBundlePreparationRequest, + SubmissionBundlePreparationResult, + SubmissionBundlePreparationStatus, + SubmissionBundlePreparationUnavailable, ) from app.modules.artifacts.models import ( ArtifactContent, @@ -28,10 +30,14 @@ SubmissionBundleAdmission, SubmissionBundleDurableIntent, ) -from app.modules.artifacts.pre_submit_evidence import PreSubmitPassCapability +from app.modules.artifacts.pre_submit_evidence import ( + PreSubmitEvidenceConflict, + PreSubmitPassCapability, +) from app.modules.artifacts.preparation import ArtifactPreparationService from app.modules.artifacts.schemas import ( ArtifactAdmissionResult, + ArtifactAuthorityDeniedError, SubmissionBundleArtifactAdmissionRequest, ) from app.modules.artifacts.submission_authorization import ( @@ -53,26 +59,46 @@ evaluate_submission_change, ) from app.modules.artifacts.submission_materialization import ( + PreparedBundleMaterializationRequest, PreparedBundleMaterializationService, PreparedBundlePreSubmitEvidenceService, ) -from app.modules.authorization.prepared import PreparedAuthorizationHandle from app.modules.checkers.api import ( + EffectivePreSubmissionExecutionPlan, + EffectivePreSubmissionPlanLineage, EffectivePreSubmissionPlanningPort, SubmissionPacketView, ) -from app.modules.tasks.pre_submit_context import ( - compile_locked_pre_submit_plan, - load_canonical_submission_version, - load_locked_pre_submit_context, +from app.modules.projects.api import ( + ProjectLockedPolicyContextFacts, + ProjectLockedPolicyContextPort, + ProjectLockedPolicyContextRequest, + ProjectLockedPolicyContextUnavailable, +) +from app.modules.tasks.api import ( + TaskSubmissionContextFacts, + TaskSubmissionContextPort, + TaskSubmissionContextRequest, + TaskSubmissionContextUnavailable, ) +def validate_submission_packet_headers(summary: str, attestation: str) -> None: + """Reject lossy header decoding only after contributor preflight succeeds.""" + try: + summary.encode("ascii") + attestation.encode("ascii") + except UnicodeEncodeError as exc: + raise SubmissionBundlePreparationRejected( + "submission_bundle_packet_header_encoding_invalid" + ) from exc + + @dataclass(frozen=True, slots=True) class SubmissionBundleDurablePutRequest: """Exact live custody and opaque authority for one final durable handoff.""" - prepared_authorization: PreparedAuthorizationHandle + prepared_authorization: object prepared_artifact: PreparedArtifact pass_capability: PreSubmitPassCapability replay_durable_intent_id: UUID | None = None @@ -119,7 +145,6 @@ async def admit_in_transaction( transaction is None or not transaction.is_active or self._session.in_nested_transaction() - or type(request.prepared_authorization) is not PreparedAuthorizationHandle or type(prepared) is not PreparedArtifact or type(request.pass_capability) is not PreSubmitPassCapability ): @@ -403,18 +428,6 @@ async def current_submission_bundle_admission_id( return UUID(value) if value is not None else None -class SubmissionBundlePreparationRejected(RuntimeError): - """The complete effective pre-submit execution did not produce passing custody.""" - - -@dataclass(frozen=True, slots=True) -class SubmissionBundlePreparationResult: - put_attempt_id: UUID - admission_id: UUID | None - status: str - replayed: bool - - @dataclass(frozen=True, slots=True) class SubmissionBundlePreparationRuntime: preparation: ArtifactPreparationService @@ -425,12 +438,6 @@ class SubmissionBundlePreparationRuntime: durable_put: SubmissionBundleDurablePutService -class SubmissionBundlePreparationCommand(SubmissionBundlePreparationPort, Protocol): - async def prepare( - self, request: SubmissionBundlePreparationRequest - ) -> SubmissionBundlePreparationResult: ... - - class PreparedSubmissionBundlePreparationCommand: """Keep every process-local capability within one hidden request.""" @@ -439,12 +446,16 @@ def __init__( *, session: AsyncSession, authority: SubmissionBundlePreparationAuthorization, + task_contexts: TaskSubmissionContextPort, + project_contexts: ProjectLockedPolicyContextPort, runtime_factory: Callable[ [], AbstractAsyncContextManager[SubmissionBundlePreparationRuntime] ], ) -> None: self._session = session self._authority = authority + self._task_contexts = task_contexts + self._project_contexts = project_contexts self._runtime_factory = runtime_factory async def prepare( @@ -454,28 +465,22 @@ async def prepare( raise TypeError("invalid submission bundle preparation request") prepared = None try: - await self._authority.preflight( - authorization_context=request.authorization_context, - task_id=request.task_id, - assignment_id=request.assignment_id, - predecessor_submission_id=request.predecessor_submission_id, - idempotency_key=request.idempotency_key, + await self._authority.preflight(request=request) + validate_submission_packet_headers( + request.summary, + request.contributor_attestation, ) if request.media_type.partition(";")[0].strip().lower() != "application/zip": raise SubmissionBundlePreparationRejected("submission_bundle_media_type_invalid") async with self._runtime_factory() as runtime: async with self._session.begin(): - locked = await load_locked_pre_submit_context( - self._session, - actor_profile_id=request.authorization_context.actor_profile_id, - identity_link_id=request.authorization_context.identity_link_id, - task_id=request.task_id, - assignment_id=request.assignment_id, - predecessor_submission_id=request.predecessor_submission_id, - include_actor_identity_locks=False, + task_context, project_context = await self._lock_context(request) + plan = self._compile_plan( + task_context, + project_context, + runtime.catalogue, ) - plan = compile_locked_pre_submit_plan(locked, runtime.catalogue) - predecessor = await self._load_predecessor(request.predecessor_submission_id) + predecessor = await self._load_predecessor(task_context) prepared = await runtime.preparation.prepare( request.byte_source, media_type="application/zip", @@ -484,8 +489,8 @@ async def prepare( materialization_handle = await runtime.materialization.prepare_authorization( task_id=request.task_id, assignment_id=request.assignment_id, - submission_artifact_policy_id=locked.effective_policy_id, - checker_policy_id=locked.pre_submit_policy_id, + submission_artifact_policy_id=project_context.effective_policy_id, + checker_policy_id=project_context.pre_submit_policy_id, prepared_artifact=prepared, effective_plan=plan, idempotency_key=request.idempotency_key, @@ -503,8 +508,13 @@ async def prepare( prepared_authorization=materialization_handle, task_id=request.task_id, assignment_id=request.assignment_id, - submission_artifact_policy_id=locked.effective_policy_id, - checker_policy_id=locked.pre_submit_policy_id, + submission_artifact_policy_id=project_context.effective_policy_id, + checker_policy_id=project_context.pre_submit_policy_id, + predecessor_submission_version=( + task_context.predecessor.version + if task_context.predecessor is not None + else None + ), prepared_artifact=prepared, effective_plan=plan, inspection=inspection, @@ -519,9 +529,7 @@ async def prepare( evidence = await runtime.evidence.persist( materialization_request, execution=execution, - actor_profile_id=request.authorization_context.actor_profile_id, - identity_link_id=request.authorization_context.identity_link_id, - predecessor_submission_id=request.predecessor_submission_id, + preparation_request=request, ) if evidence.pass_capability is None: replay = await self._existing_durable_result(evidence.evidence.evidence_set_id) @@ -536,13 +544,7 @@ async def prepare( evidence.evidence.evidence_set_id ) async with self._authority.transaction(): - final_handle = await self._authority.prepare_final( - authorization_context=request.authorization_context, - task_id=request.task_id, - assignment_id=request.assignment_id, - predecessor_submission_id=request.predecessor_submission_id, - idempotency_key=request.idempotency_key, - ) + final_handle = await self._authority.prepare_final(request=request) retained, _, durable = await runtime.durable_put.admit_in_transaction( SubmissionBundleDurablePutRequest( prepared_authorization=final_handle, @@ -558,6 +560,14 @@ async def prepare( durable, ) return self._result(result) + except PreSubmitEvidenceConflict as exc: + raise SubmissionBundlePreparationRejected( + self._evidence_conflict_code(exc) + ) from exc + except ArtifactAuthorityDeniedError as exc: + raise SubmissionBundlePreparationUnavailable( + "submission bundle preparation is unavailable" + ) from exc finally: if prepared is not None: await prepared.close() @@ -633,6 +643,91 @@ async def _matching_replay_intent(self, evidence_id: UUID) -> UUID | None: value = await self._session.scalar(statement) return UUID(value) if value is not None else None + @staticmethod + def _evidence_conflict_code(exc: PreSubmitEvidenceConflict) -> str: + """Map ART-private evidence failures to the bounded public vocabulary.""" + if str(exc) == "pre_submit_locked_context_changed": + return "submission_bundle_preparation_context_changed" + return "pre_submission_checked_custody_unavailable" + + async def _lock_context( + self, + request: SubmissionBundlePreparationRequest, + ) -> tuple[TaskSubmissionContextFacts, ProjectLockedPolicyContextFacts]: + """Lock exact TASK then PROJECT facts through their public ports.""" + try: + task_context = await self._task_contexts.lock_submission_context( + TaskSubmissionContextRequest( + task_id=request.task_id, + assignment_id=request.assignment_id, + contributor_id=request.actor.actor_profile_id, + predecessor_submission_id=request.predecessor_submission_id, + ) + ) + references = task_context.locked_project_context + project_context = await self._project_contexts.lock_locked_policy_context( + ProjectLockedPolicyContextRequest( + project_id=references.project_id, + guide_version=references.guide_version, + source_snapshot_id=references.source_snapshot_id, + source_snapshot_hash=references.source_snapshot_hash, + effective_policy_id=references.effective_policy_id, + effective_policy_hash=references.effective_policy_hash, + pre_submit_policy_id=references.pre_submit_policy_id, + pre_submit_policy_bundle_hash=references.pre_submit_policy_bundle_hash, + ) + ) + except (TaskSubmissionContextUnavailable, ProjectLockedPolicyContextUnavailable) as exc: + raise SubmissionBundlePreparationRejected( + "submission_bundle_preparation_context_changed" + ) from exc + return task_context, project_context + + @staticmethod + def _compile_plan( + task_context: TaskSubmissionContextFacts, + project_context: ProjectLockedPolicyContextFacts, + planner: EffectivePreSubmissionPlanningPort, + ) -> EffectivePreSubmissionExecutionPlan: + """Compile the sole CHECKER plan from exact public PROJECT facts.""" + guide_version = project_context.guide_version.removeprefix("v") + try: + numeric_guide_version = int(guide_version) + effective_policy = json.loads(project_context.effective_policy.value) + compiled_bundle = json.loads( + project_context.compiled_pre_submit_bundle.value + ) + except (TypeError, ValueError) as exc: + raise SubmissionBundlePreparationRejected( + "submission_bundle_preparation_context_changed" + ) from exc + if ( + project_context.project_id + != task_context.locked_project_context.project_id + or not isinstance(effective_policy, dict) + or not isinstance(compiled_bundle, dict) + ): + raise SubmissionBundlePreparationRejected( + "submission_bundle_preparation_context_changed" + ) + return planner.compile_effective_plan( + lineage=EffectivePreSubmissionPlanLineage( + project_id=project_context.project_id, + guide_id=project_context.guide_id, + guide_version=numeric_guide_version, + source_snapshot_id=project_context.source_snapshot_id, + source_snapshot_hash=project_context.source_snapshot_hash, + effective_policy_id=project_context.effective_policy_id, + effective_policy_hash=project_context.effective_policy_hash, + pre_submit_policy_id=project_context.pre_submit_policy_id, + pre_submit_policy_bundle_hash=( + project_context.pre_submit_policy_bundle_hash + ), + ), + effective_policy=effective_policy, + compiled_bundle=compiled_bundle, + ) + async def _existing_durable_result( self, evidence_id: UUID ) -> SubmissionBundlePreparationResult | None: @@ -664,31 +759,33 @@ async def _existing_durable_result( return SubmissionBundlePreparationResult( put_attempt_id=UUID(attempt.id), admission_id=UUID(admission.id) if admission is not None else None, - status="ready" if admission is not None else attempt.status, + submission_bundle_preparation_status=SubmissionBundlePreparationStatus( + "ready" if admission is not None else attempt.status + ), replayed=True, ) async def _load_predecessor( - self, submission_id: UUID | None + self, + task_context: TaskSubmissionContextFacts, ) -> SubmissionCanonicalPredecessor | None: - if submission_id is None: + predecessor = task_context.predecessor + if predecessor is None: return None admission = await self._session.scalar( select(SubmissionBundleAdmission).where( - SubmissionBundleAdmission.consumed_by_submission_id == str(submission_id), + SubmissionBundleAdmission.consumed_by_submission_id + == str(predecessor.submission_id), SubmissionBundleAdmission.status == "consumed", ) ) - version = await load_canonical_submission_version( - self._session, submission_id=submission_id - ) - if admission is None or version is None: + if admission is None: raise SubmissionBundlePreparationRejected( "submission_canonical_predecessor_unavailable" ) return SubmissionCanonicalPredecessor( - submission_id=submission_id, - submission_version=version, + submission_id=predecessor.submission_id, + submission_version=predecessor.version, archive_sha256=admission.archive_sha256, semantic_manifest_sha256=admission.semantic_manifest_sha256, ) @@ -698,6 +795,8 @@ def _result(result: SubmissionBundleDurablePutResult) -> SubmissionBundlePrepara return SubmissionBundlePreparationResult( put_attempt_id=result.put_attempt_id, admission_id=result.admission_id, - status=result.status, + submission_bundle_preparation_status=SubmissionBundlePreparationStatus( + result.status + ), replayed=result.replayed, ) diff --git a/backend/app/modules/artifacts/submission_authorization.py b/backend/app/modules/artifacts/submission_authorization.py index 28e2560c9..51c967b97 100644 --- a/backend/app/modules/artifacts/submission_authorization.py +++ b/backend/app/modules/artifacts/submission_authorization.py @@ -4,14 +4,11 @@ from contextlib import AbstractAsyncContextManager from typing import Protocol -from uuid import UUID - +from app.modules.artifacts.api import SubmissionBundlePreparationRequest from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, SubmissionBundleDurableIntentAuthorityFacts, ) -from app.modules.authorization.prepared import PreparedAuthorizationHandle -from app.modules.authorization.runtime import AuthorizationContext class SubmissionBundlePreparedAuthorization(Protocol): @@ -20,7 +17,7 @@ class SubmissionBundlePreparedAuthorization(Protocol): async def consume( self, *, - prepared_authorization: PreparedAuthorizationHandle, + prepared_authorization: object, facts: SubmissionBundleDurableIntentAuthorityFacts, ) -> None: ... @@ -31,11 +28,13 @@ class SubmissionBundlePreparationAuthorization(SubmissionBundlePreparedAuthoriza async def preflight( self, *, - authorization_context: AuthorizationContext, - task_id: UUID, - assignment_id: UUID, - predecessor_submission_id: UUID | None, - idempotency_key: UUID, + request: SubmissionBundlePreparationRequest, + ) -> None: ... + + async def revalidate( + self, + *, + request: SubmissionBundlePreparationRequest, ) -> None: ... def transaction(self) -> AbstractAsyncContextManager[object]: ... @@ -43,12 +42,8 @@ def transaction(self) -> AbstractAsyncContextManager[object]: ... async def prepare_final( self, *, - authorization_context: AuthorizationContext, - task_id: UUID, - assignment_id: UUID, - predecessor_submission_id: UUID | None, - idempotency_key: UUID, - ) -> PreparedAuthorizationHandle: ... + request: SubmissionBundlePreparationRequest, + ) -> object: ... def close(self) -> None: ... @@ -59,7 +54,7 @@ class DenySubmissionBundlePreparedAuthorization: async def consume( self, *, - prepared_authorization: PreparedAuthorizationHandle, + prepared_authorization: object, facts: SubmissionBundleDurableIntentAuthorityFacts, ) -> None: del prepared_authorization, facts @@ -73,10 +68,14 @@ async def preflight(self, **values: object) -> None: del values raise ArtifactAuthorityDeniedError("submission bundle preparation is unavailable") + async def revalidate(self, **values: object) -> None: + del values + raise ArtifactAuthorityDeniedError("submission bundle preparation is unavailable") + def transaction(self) -> AbstractAsyncContextManager[object]: raise ArtifactAuthorityDeniedError("submission bundle preparation is unavailable") - async def prepare_final(self, **values: object) -> PreparedAuthorizationHandle: + async def prepare_final(self, **values: object) -> object: del values raise ArtifactAuthorityDeniedError("submission bundle preparation is unavailable") diff --git a/backend/app/modules/artifacts/submission_materialization.py b/backend/app/modules/artifacts/submission_materialization.py index 1fd78a8dd..7513617ec 100644 --- a/backend/app/modules/artifacts/submission_materialization.py +++ b/backend/app/modules/artifacts/submission_materialization.py @@ -10,29 +10,87 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.hashing import canonical_json_hash -from app.interfaces.artifact_operations import PreparedBundleMaterializationRequest -from app.modules.actors.service_identities import ServiceIdentity +from app.modules.artifacts.api import SubmissionBundlePreparationRequest from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError from app.modules.artifacts.preparation import ArtifactPreparationService from app.modules.artifacts.sources import PreparedArtifact from app.modules.artifacts.pre_submit_evidence import ( + PreSubmitExecutionCustody, + PreSubmitExecutionResult, PreSubmitEvidencePersistenceRequest, PreSubmitEvidencePersistenceResult, PreSubmitEvidenceService, ) -from app.modules.artifacts.submission_archive import SubmissionArchiveInspector -from app.modules.artifacts.submission_manifest import build_submission_manifest -from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.prepared import PreparedAuthorizationHandle -from app.modules.checkers.catalogue import PreSubmissionCheckerCatalogue -from app.modules.checkers.effective_plan import EffectivePreSubmissionExecutionPlan -from app.modules.checkers.pre_submit_execution import ( +from app.modules.artifacts.submission_archive import ( + SubmissionArchiveInspectionResult, +) +from app.modules.artifacts.submission_manifest import ( + SubmissionChangeGateResult, + SubmissionManifest, + build_submission_manifest, +) +from app.modules.artifacts.submission_authorization import ( + SubmissionBundlePreparationAuthorization, +) +from app.modules.checkers.api import ( ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, - DefaultPreSubmissionExecutionInput, - EffectivePreSubmissionProcessor, - PreSubmissionExecutionResult, - PreSubmissionInfrastructureUnavailable, + EffectivePreSubmissionExecutionPlan, + PreSubmissionExecutionFacts, + PreSubmissionInfrastructureUnavailableError, + SubmissionPacketView, ) +from app.modules.projects.api import ProjectLockedPolicyContextPort +from app.modules.tasks.api import TaskSubmissionContextPort + +@dataclass(frozen=True, slots=True) +class PreparedBundleMaterializationRequest: + """ART-private process-local prepared bytes and exact execution facts.""" + + prepared_authorization: object + task_id: UUID + assignment_id: UUID + submission_artifact_policy_id: UUID + checker_policy_id: UUID + predecessor_submission_version: int | None + prepared_artifact: PreparedArtifact + effective_plan: EffectivePreSubmissionExecutionPlan + inspection: SubmissionArchiveInspectionResult + manifest: SubmissionManifest + change_gate: SubmissionChangeGateResult + packet: SubmissionPacketView + + +@dataclass(frozen=True, slots=True) +class PreSubmitCheckerExecutionRequest: + """Exact process-local input supplied to the composition CHECKER adapter.""" + + plan: EffectivePreSubmissionExecutionPlan + commitment: object + inspection: SubmissionArchiveInspectionResult + manifest: SubmissionManifest + change_gate: SubmissionChangeGateResult + packet: SubmissionPacketView + prepared_generation_id: UUID + storage_scheme: str + + +class PreSubmitCheckerProcessor(Protocol): + """Async processor built by the composition-root CHECKER adapter.""" + + def abort(self) -> None: ... + + async def process( + self, reader: object, workspace: object + ) -> PreSubmissionExecutionFacts: ... + + +class PreSubmitCheckerExecutionFactory(Protocol): + """Build one process-local CHECKER processor without private imports in ART.""" + + @property + def catalogue_manifest_sha256(self) -> str: ... + + def build(self, request: PreSubmitCheckerExecutionRequest) -> PreSubmitCheckerProcessor: ... @dataclass(frozen=True, slots=True) @@ -97,16 +155,14 @@ async def prepare( *, facts: PreSubmitMaterializationPreparationFacts, idempotency_key: UUID, - ) -> PreparedAuthorizationHandle: + ) -> object: """Prepare an opaque capability before any submitted byte is inspected.""" ... async def consume( self, *, - service_identity: ServiceIdentity, - action_id: ActionId, - prepared_authorization: PreparedAuthorizationHandle, + prepared_authorization: object, facts: PreSubmitMaterializationAuthorityFacts, ) -> None: """Consume the capability against the server-computed final facts.""" @@ -121,7 +177,7 @@ async def prepare( *, facts: PreSubmitMaterializationPreparationFacts, idempotency_key: UUID, - ) -> PreparedAuthorizationHandle: + ) -> object: """Deny capability preparation while the production adapter is absent.""" del facts, idempotency_key raise ArtifactAuthorityDeniedError( @@ -131,13 +187,11 @@ async def prepare( async def consume( self, *, - service_identity: ServiceIdentity, - action_id: ActionId, - prepared_authorization: PreparedAuthorizationHandle, + prepared_authorization: object, facts: PreSubmitMaterializationAuthorityFacts, ) -> None: """Deny capability consumption while the production adapter is absent.""" - del service_identity, action_id, prepared_authorization, facts + del prepared_authorization, facts raise ArtifactAuthorityDeniedError( "pre-submit checker input materialization is unavailable" ) @@ -151,15 +205,13 @@ def __init__( *, authorization: PreSubmitMaterializationAuthorization, preparation: ArtifactPreparationService, - archive_inspector: SubmissionArchiveInspector, - catalogue: PreSubmissionCheckerCatalogue, + checker_execution: PreSubmitCheckerExecutionFactory, storage_scheme: str, ) -> None: """Compose the bounded materializer from its AUTH and ART dependencies.""" self._authorization = authorization self._preparation = preparation - self._archive_inspector = archive_inspector - self._catalogue = catalogue + self._checker_execution = checker_execution if storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES: raise ValueError("pre-submit materializer storage scheme is invalid") self._storage_scheme = storage_scheme @@ -167,19 +219,15 @@ def __init__( async def materialize_prepared_bundle( self, request: PreparedBundleMaterializationRequest, - ) -> PreSubmissionExecutionResult: + ) -> PreSubmitExecutionResult: """Consume fixed-service authority before any byte or workspace access.""" facts = self._authority_facts(request) await self._authorization.consume( - service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, - action_id=ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, prepared_authorization=request.prepared_authorization, facts=facts, ) - processor = EffectivePreSubmissionProcessor( - archive_inspector=self._archive_inspector, - catalogue=self._catalogue, - execution_input=DefaultPreSubmissionExecutionInput( + processor = self._checker_execution.build( + PreSubmitCheckerExecutionRequest( plan=request.effective_plan, commitment=request.prepared_artifact.commitment, inspection=request.inspection, @@ -192,12 +240,22 @@ async def materialize_prepared_bundle( ) # Intentional friend call: this is the sole authority-gated caller, and # the preparation byte-access surface must remain private. - return await self._preparation._process_prepared_submission( + checker_facts = await self._preparation._process_prepared_submission( request.prepared_artifact, processor, reserved_bytes=request.manifest.total_expanded_bytes, maximum_entries=request.manifest.entry_count, ) + return PreSubmitExecutionResult( + custody=PreSubmitExecutionCustody( + prepared_generation_id=request.prepared_artifact.generation_id, + archive_sha256=request.prepared_artifact.commitment.sha256, + archive_byte_count=request.prepared_artifact.commitment.byte_count, + semantic_manifest_sha256=request.manifest.sha256, + storage_scheme=self._storage_scheme, + ), + checker_facts=checker_facts, + ) async def prepare_authorization( self, @@ -209,7 +267,7 @@ async def prepare_authorization( prepared_artifact: PreparedArtifact, effective_plan: EffectivePreSubmissionExecutionPlan, idempotency_key: UUID, - ) -> PreparedAuthorizationHandle: + ) -> object: """Deny unavailable service authority before inspecting the ZIP.""" facts = self._preparation_facts( task_id=task_id, @@ -245,7 +303,7 @@ def _authority_facts( or request.prepared_artifact.commitment.byte_count != request.change_gate.archive_byte_count ): - raise PreSubmissionInfrastructureUnavailable( + raise PreSubmissionInfrastructureUnavailableError( "pre_submission_materialization_context_invalid" ) return PreSubmitMaterializationAuthorityFacts( @@ -266,13 +324,16 @@ def _preparation_facts( """Build pre-inspection facts from locked lineage and byte commitment.""" plan = effective_plan if plan.plan_sha256 != canonical_json_hash(plan.as_dict()): - raise PreSubmissionInfrastructureUnavailable("pre_submission_plan_identity_invalid") + raise PreSubmissionInfrastructureUnavailableError( + "pre_submission_plan_identity_invalid" + ) if ( submission_artifact_policy_id != plan.lineage.effective_policy_id or checker_policy_id != plan.lineage.pre_submit_policy_id - or plan.catalogue_manifest_sha256 != self._catalogue.manifest_sha256 + or plan.catalogue_manifest_sha256 + != self._checker_execution.catalogue_manifest_sha256 ): - raise PreSubmissionInfrastructureUnavailable( + raise PreSubmissionInfrastructureUnavailableError( "pre_submission_materialization_context_invalid" ) commitment = prepared_artifact.commitment @@ -305,18 +366,22 @@ def __init__( *, session: AsyncSession, materialization: PreparedBundleMaterializationService, + preparation_authorization: SubmissionBundlePreparationAuthorization, + task_contexts: TaskSubmissionContextPort, + project_contexts: ProjectLockedPolicyContextPort, ) -> None: """Bind execution to the transaction used for durable evidence.""" self._session = session self._materialization = materialization + self._preparation_authorization = preparation_authorization + self._task_contexts = task_contexts + self._project_contexts = project_contexts async def execute( self, request: PreparedBundleMaterializationRequest, *, - actor_profile_id: UUID, - identity_link_id: UUID, - predecessor_submission_id: UUID | None, + preparation_request: SubmissionBundlePreparationRequest, ) -> PreSubmitEvidencePersistenceResult: """Persist only after materialization has returned and cleaned its scratch lease.""" if self._session.in_transaction(): @@ -327,14 +392,12 @@ async def execute( return await self.persist( request, execution=execution, - actor_profile_id=actor_profile_id, - identity_link_id=identity_link_id, - predecessor_submission_id=predecessor_submission_id, + preparation_request=preparation_request, ) async def materialize( self, request: PreparedBundleMaterializationRequest - ) -> PreSubmissionExecutionResult: + ) -> PreSubmitExecutionResult: """Consume fixed-service authority while its owning transaction is active.""" return await self._materialization.materialize_prepared_bundle(request) @@ -342,10 +405,8 @@ async def persist( self, request: PreparedBundleMaterializationRequest, *, - execution: PreSubmissionExecutionResult, - actor_profile_id: UUID, - identity_link_id: UUID, - predecessor_submission_id: UUID | None, + execution: PreSubmitExecutionResult, + preparation_request: SubmissionBundlePreparationRequest, ) -> PreSubmitEvidencePersistenceResult: """Persist completed, cleaned execution evidence in a fresh transaction.""" if self._session.in_transaction(): @@ -356,13 +417,23 @@ async def persist( prepared_generation_id = request.prepared_artifact.generation_id async with self._session.begin(): await self._session.execute(text("set transaction isolation level read committed")) - return await PreSubmitEvidenceService(self._session).persist( + await self._preparation_authorization.revalidate(request=preparation_request) + return await PreSubmitEvidenceService( + self._session, + task_contexts=self._task_contexts, + project_contexts=self._project_contexts, + ).persist( PreSubmitEvidencePersistenceRequest( - actor_profile_id=actor_profile_id, - identity_link_id=identity_link_id, + actor_profile_id=preparation_request.actor.actor_profile_id, + identity_link_id=preparation_request.actor.identity_link_id, task_id=request.task_id, assignment_id=request.assignment_id, - predecessor_submission_id=predecessor_submission_id, + predecessor_submission_id=( + preparation_request.predecessor_submission_id + ), + expected_predecessor_submission_version=( + request.predecessor_submission_version + ), prepared_generation_id=prepared_generation_id, archive_sha256=commitment.sha256, archive_byte_count=commitment.byte_count, diff --git a/backend/app/modules/checkers/api/__init__.py b/backend/app/modules/checkers/api/__init__.py index ee6b11fd9..93506d5b3 100644 --- a/backend/app/modules/checkers/api/__init__.py +++ b/backend/app/modules/checkers/api/__init__.py @@ -1,6 +1,7 @@ """Dependency-safe public API for the CHECKERS business module.""" from app.modules.checkers.api.pre_submit import ( + ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, EffectivePreSubmissionExecutionPlan, EffectivePreSubmissionPlanEntry, EffectivePreSubmissionPlanError, @@ -10,10 +11,13 @@ PreSubmissionExecutionEntryFacts, PreSubmissionExecutionFacts, PreSubmissionInfrastructureUnavailableError, + PRE_SUBMISSION_RESULT_METADATA_KEYS, SubmissionPacketView, + validate_pre_submission_execution_facts, ) __all__ = ( + "ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES", "EffectivePreSubmissionExecutionPlan", "EffectivePreSubmissionPlanEntry", "EffectivePreSubmissionPlanError", @@ -23,5 +27,7 @@ "PreSubmissionExecutionEntryFacts", "PreSubmissionExecutionFacts", "PreSubmissionInfrastructureUnavailableError", + "PRE_SUBMISSION_RESULT_METADATA_KEYS", "SubmissionPacketView", + "validate_pre_submission_execution_facts", ) diff --git a/backend/app/modules/checkers/api/pre_submit.py b/backend/app/modules/checkers/api/pre_submit.py index 5608e058e..e23455a0a 100644 --- a/backend/app/modules/checkers/api/pre_submit.py +++ b/backend/app/modules/checkers/api/pre_submit.py @@ -9,6 +9,32 @@ EFFECTIVE_PRE_SUBMISSION_PLAN_SCHEMA_VERSION = "effective_pre_submission_plan.v1" EFFECTIVE_PRE_SUBMISSION_PLAN_HASH_DOMAIN = "workstream.effective_pre_submission_plan.v1" +ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES = frozenset({"local", "s3"}) +_RESULT_MESSAGE_CODES = frozenset( + { + "advisory_disabled", + "attestation_missing", + "dependency_not_run", + "file_size_limit_exceeded", + "forbidden_artifact_present", + "package_size_limit_exceeded", + "packaging_requirement_failed", + "passed", + "policy_attestation_missing", + "quality_signal_warning", + "required_evidence_missing", + "required_file_missing", + "sensitive_path_forbidden", + "storage_scheme_not_allowed", + "submission_packet_invalid", + } +) +PRE_SUBMISSION_RESULT_METADATA_KEYS = frozenset( + {"entry_count", "finding_count", "matched_category_count"} +) +_EXECUTION_STATUSES = frozenset( + {"passed", "warning", "advisory_disabled", "dependency_not_run", "failed"} +) class EffectivePreSubmissionPlanError(ValueError): @@ -206,6 +232,60 @@ class PreSubmissionExecutionFacts: entries: tuple[PreSubmissionExecutionEntryFacts, ...] +def validate_pre_submission_execution_facts( + plan: EffectivePreSubmissionExecutionPlan, + execution: PreSubmissionExecutionFacts, +) -> None: + """Validate one bounded CHECKER result against its exact immutable plan.""" + if ( + type(execution.eligible) is not bool + or execution.plan_sha256 != plan.plan_sha256 + or len(execution.entries) != len(plan.entries) + ): + raise PreSubmissionInfrastructureUnavailableError( + "pre_submission_result_context_invalid" + ) + disqualified = False + for plan_entry, result in zip(plan.entries, execution.entries, strict=True): + expected_severity = ( + "warning" if plan_entry.classification == "advisory" else "blocking" + ) + status = result.checker_execution_status + if ( + result.dispatch_authority != "workstream.pre_submission_checker_catalogue" + or result.definition_id != plan_entry.definition_id + or result.definition_version != plan_entry.definition_version + or result.public_name != plan_entry.public_name + or result.policy_source != plan_entry.policy_trace_source + or result.effective_plan_sha256 != plan.plan_sha256 + or result.rule_instance_id != plan_entry.rule_instance_id + or result.locked_policy_sha256 != plan.lineage.effective_policy_hash + or result.phase != plan_entry.phase + or result.order != plan_entry.order + or result.classification != plan_entry.classification + or result.severity != expected_severity + or status not in _EXECUTION_STATUSES + or result.message_code not in _RESULT_MESSAGE_CODES + or result.failure_code + != (plan_entry.failure_code if status == "failed" else None) + or len(result.metadata) != len({key for key, _ in result.metadata}) + or any( + key not in PRE_SUBMISSION_RESULT_METADATA_KEYS + or type(value) is not int + or value < 0 + for key, value in result.metadata + ) + ): + raise PreSubmissionInfrastructureUnavailableError( + "pre_submission_result_context_invalid" + ) + disqualified = disqualified or status in {"failed", "dependency_not_run"} + if execution.eligible == disqualified: + raise PreSubmissionInfrastructureUnavailableError( + "pre_submission_result_context_invalid" + ) + + def effective_plan_body( lineage: EffectivePreSubmissionPlanLineage, catalogue_id: str, diff --git a/backend/app/modules/checkers/pre_submit_execution.py b/backend/app/modules/checkers/pre_submit_execution.py index 46a3c4fcd..f75d8e265 100644 --- a/backend/app/modules/checkers/pre_submit_execution.py +++ b/backend/app/modules/checkers/pre_submit_execution.py @@ -13,11 +13,15 @@ from app.core.hashing import canonical_json_hash from app.modules.checkers.api import ( + ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, EffectivePreSubmissionExecutionPlan, EffectivePreSubmissionPlanEntry, PreSubmissionExecutionEntryFacts, PreSubmissionExecutionFacts, + PreSubmissionInfrastructureUnavailableError, + PRE_SUBMISSION_RESULT_METADATA_KEYS, SubmissionPacketView, + validate_pre_submission_execution_facts, ) from app.modules.artifacts.sources import ArtifactCommitment from app.modules.artifacts.submission_archive import ( @@ -55,29 +59,6 @@ _FORBIDDEN_EXACT_NAMES = frozenset({".env", "id_rsa", "id_ed25519"}) _FORBIDDEN_DIRECTORY_NAMES = frozenset({".git"}) _FORBIDDEN_SUFFIXES = (".pem", ".key") -_RESULT_MESSAGE_CODES = frozenset( - { - "advisory_disabled", - "attestation_missing", - "dependency_not_run", - "file_size_limit_exceeded", - "forbidden_artifact_present", - "package_size_limit_exceeded", - "packaging_requirement_failed", - "passed", - "policy_attestation_missing", - "quality_signal_warning", - "required_evidence_missing", - "required_file_missing", - "sensitive_path_forbidden", - "storage_scheme_not_allowed", - "submission_packet_invalid", - } -) -_RESULT_METADATA_KEYS = frozenset({"entry_count", "finding_count", "matched_category_count"}) -ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES = frozenset({"local", "s3"}) - - class DefaultPreSubmissionExecutionError(RuntimeError): """Fail hidden execution without creating a durable checker effect.""" @@ -178,7 +159,7 @@ def bounded_facts(self) -> PreSubmissionExecutionFacts: metadata=tuple( (key, value) for key, value in entry.metadata - if key in _RESULT_METADATA_KEYS + if key in PRE_SUBMISSION_RESULT_METADATA_KEYS and type(value) is int and value >= 0 ), @@ -604,46 +585,34 @@ def validate_pre_submission_execution_result( or len(execution.entries) != len(plan.entries) ): raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") - disqualified = False for plan_entry, result in zip(plan.entries, execution.entries, strict=True): - expected_severity = "warning" if plan_entry.classification == "advisory" else "blocking" + metadata = result.metadata + if type(metadata) is not tuple or any( + type(item) is not tuple + or len(item) != 2 + or type(item[0]) is not str + or type(item[1]) is not int + for item in metadata + ): + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_result_context_invalid" + ) + metadata_keys = tuple(item[0] for item in metadata) if ( type(result.status) is not PreSubmissionResultStatus or result.schema_version != plan_entry.result_schema - or result.definition.dispatch_authority != "workstream.pre_submission_checker_catalogue" - or result.definition.definition_id != plan_entry.definition_id - or result.definition.definition_version != plan_entry.definition_version - or result.definition.public_name != plan_entry.public_name - or result.definition.source != plan_entry.policy_trace_source - or result.policy_trace.effective_plan_sha256 != plan.plan_sha256 - or result.policy_trace.rule_instance_id != plan_entry.rule_instance_id - or result.policy_trace.locked_policy_sha256 != plan.lineage.effective_policy_hash - or result.phase != plan_entry.phase - or result.order != plan_entry.order - or result.classification != plan_entry.classification - or result.severity != expected_severity - or result.message_code not in _RESULT_MESSAGE_CODES - or ( - result.failure_code - != ( - plan_entry.failure_code - if result.status is PreSubmissionResultStatus.FAILED - else None - ) - ) - or len(result.metadata) != len({key for key, _ in result.metadata}) + or len(metadata_keys) != len(set(metadata_keys)) or any( - key not in _RESULT_METADATA_KEYS or type(value) is not int or value < 0 - for key, value in result.metadata + key not in PRE_SUBMISSION_RESULT_METADATA_KEYS + or value < 0 + for key, value in metadata ) ): raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") - disqualified = disqualified or result.status in { - PreSubmissionResultStatus.FAILED, - PreSubmissionResultStatus.DEPENDENCY_NOT_RUN, - } - if execution.eligible == disqualified: - raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") + try: + validate_pre_submission_execution_facts(plan, execution.bounded_facts()) + except PreSubmissionInfrastructureUnavailableError as exc: + raise PreSubmissionInfrastructureUnavailable(str(exc)) from exc def _is_high_confidence_sensitive(normalized_path: str) -> bool: diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index 1f1e62d72..dac7b68ce 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -3,26 +3,14 @@ from __future__ import annotations from typing import Annotated -from uuid import UUID - -from fastapi import APIRouter, Depends, Header, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse -from pydantic import BaseModel, ConfigDict from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps.auth import actor_registry_http_error, get_registered_actor from app.core.api_controls import StructuredHTTPException, error_response from app.core.permissions import PermissionDenied from app.db.session import get_db_session -from app.adapters.artifacts import get_submission_bundle_preparation_command -from app.interfaces.artifact_operations import SubmissionBundlePreparationRequest -from app.modules.artifacts.authorization import get_artifact_authorization_context -from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError -from app.modules.artifacts.submission_admission import ( - SubmissionBundlePreparationCommand, - SubmissionBundlePreparationRejected, -) -from app.modules.authorization.runtime import AuthorizationContext from app.modules.actors.schemas import ( LegacyWorkflowEligibilityActivationRequest, LegacyWorkflowEligibilityResponse, @@ -40,24 +28,12 @@ TaskWorkContextResponse, TaskWithAssignmentResponse, ) -from app.modules.tasks.pre_submit_context import PreSubmitLockedContextInvalid from app.modules.tasks.service import TaskService, TaskServiceError from app.schemas.auth import ActorContext router = APIRouter(tags=["tasks"]) -class SubmissionBundlePreparationResponse(BaseModel): - """Bounded hidden operation state without provider or scratch coordinates.""" - - model_config = ConfigDict(extra="forbid", from_attributes=True) - - put_attempt_id: UUID - admission_id: UUID | None - status: str - replayed: bool - - CANONICAL_ERROR_OBJECT_SCHEMA = {"$ref": "#/components/schemas/ApiError"} PRE_SUBMIT_DOMAIN_ERROR_RESPONSE_SCHEMA = { "oneOf": [ @@ -79,18 +55,6 @@ class SubmissionBundlePreparationResponse(BaseModel): } -def _require_ascii_submission_packet_headers(summary: str, attestation: str) -> None: - """Reject lossy HTTP-header decoding before immutable evidence hashing.""" - try: - summary.encode("ascii") - attestation.encode("ascii") - except UnicodeEncodeError as exc: - raise HTTPException( - status_code=422, - detail="submission_bundle_packet_header_encoding_invalid", - ) from exc - - TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA = { "oneOf": [ { @@ -406,71 +370,6 @@ async def start_task( raise task_http_error(exc) from exc -@router.post( - "/tasks/{task_id}/submission-bundle-preparations", - response_model=SubmissionBundlePreparationResponse, - status_code=status.HTTP_202_ACCEPTED, - include_in_schema=False, -) -async def prepare_submission_bundle( - task_id: str, - request: Request, - context: Annotated[AuthorizationContext, Depends(get_artifact_authorization_context)], - command: Annotated[ - SubmissionBundlePreparationCommand, - Depends(get_submission_bundle_preparation_command), - ], - assignment_id: Annotated[str | None, Header(alias="X-Task-Assignment-Id")] = None, - idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, - summary: Annotated[str | None, Header(alias="X-Submission-Summary")] = None, - contributor_attestation: Annotated[ - str | None, Header(alias="X-Contributor-Attestation") - ] = None, - predecessor_submission_id: Annotated[ - str | None, Header(alias="X-Predecessor-Submission-Id") - ] = None, -) -> SubmissionBundlePreparationResponse: - """Run the hidden continuous ZIP preparation surface; AUTH remains fail closed.""" - if None in (assignment_id, idempotency_key, summary, contributor_attestation): - raise HTTPException(status_code=404, detail="Task not found") - assert assignment_id is not None and idempotency_key is not None - assert summary is not None and contributor_attestation is not None - _require_ascii_submission_packet_headers(summary, contributor_attestation) - try: - identifiers = ( - UUID(task_id), - UUID(assignment_id), - UUID(idempotency_key), - UUID(predecessor_submission_id) if predecessor_submission_id else None, - ) - except ValueError as exc: - raise HTTPException(status_code=404, detail="Task not found") from exc - try: - result = await command.prepare( - SubmissionBundlePreparationRequest( - authorization_context=context, - task_id=identifiers[0], - assignment_id=identifiers[1], - predecessor_submission_id=identifiers[3], - idempotency_key=identifiers[2], - summary=summary, - contributor_attestation=contributor_attestation, - media_type=request.headers.get("content-type", ""), - byte_source=request.stream(), - ) - ) - except ArtifactAuthorityDeniedError as exc: - raise HTTPException(status_code=404, detail="Task not found") from exc - except SubmissionBundlePreparationRejected as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - except PreSubmitLockedContextInvalid as exc: - raise HTTPException( - status_code=409, - detail="submission_bundle_preparation_context_changed", - ) from exc - return SubmissionBundlePreparationResponse.model_validate(result, from_attributes=True) - - @router.post( "/tasks/{task_id}/submissions", response_model=SubmissionResponse, diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 9781c67e0..af7cd8680 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -76,6 +76,8 @@ ) MODULE_PUBLIC_API_FOUNDATION_TARGETS = frozenset( { + "backend/app/api/routes/artifact_submissions.py", + "backend/app/modules/artifacts/api/submission_preparation.py", "backend/app/modules/checkers/api/pre_submit.py", "backend/app/modules/projects/api/locked_policy.py", "backend/app/modules/projects/locked_policy_repository.py", diff --git a/backend/scripts/module_boundaries.py b/backend/scripts/module_boundaries.py index 677f02430..0136b1a01 100644 --- a/backend/scripts/module_boundaries.py +++ b/backend/scripts/module_boundaries.py @@ -122,6 +122,17 @@ def _source_module(source: str) -> str | None: return source.removeprefix(prefix).split("/", 1)[0] +def _source_owner_adapter(source: str) -> str | None: + """Return the module owned by one exact adapter composition root.""" + prefix = "backend/app/adapters/" + if not source.startswith(prefix): + return None + parts = source.removeprefix(prefix).split("/") + if len(parts) != 2 or parts[1] != "__init__.py": + return None + return parts[0] + + def _is_public_target(target: str, module: str) -> bool: public = f"{MODULE_PREFIX}{module}.api" return target == public or target.startswith(f"{public}.") @@ -187,6 +198,7 @@ def scan(root: Path, registry: Registry) -> tuple[set[PrivateEdge], dict[str, se for path in sorted(app_root.rglob("*.py")): source = path.relative_to(root).as_posix() source_module = _source_module(source) + source_owner_adapter = _source_owner_adapter(source) canonical_imports = authorization_boundary.source_imports(path, root) exact_imports = exact_source_imports(path, root, source_validated=True) for target in canonical_imports: @@ -201,6 +213,8 @@ def scan(root: Path, registry: Registry) -> tuple[set[PrivateEdge], dict[str, se if source_module == "authorization" or target_module == "authorization": if not _is_public_target(target, target_module): auth_edges.add(edge) + if source_owner_adapter == target_module: + continue for target in exact_imports: target_module = _module_from_target(target) if target_module is None: @@ -209,6 +223,8 @@ def scan(root: Path, registry: Registry) -> tuple[set[PrivateEdge], dict[str, se raise ModuleBoundaryError("unknown_module") if source_module == target_module: continue + if source_owner_adapter == target_module: + continue if source_module == "authorization" or target_module == "authorization": continue if _is_public_target(target, target_module): diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index f0406fb3e..94ac8de6f 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -142,6 +142,7 @@ class TestLane: "tests/test_behavior_ownership.py", "tests/test_artifact_admission.py", "tests/test_submission_bundle_admission.py", + "tests/test_pre_submit_evidence_relock.py", "tests/test_artifact_operator_api.py", "tests/test_artifact_recovery.py", "tests/test_db_session.py", diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index 1fdad07df..1ace4421c 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -85,6 +85,51 @@ def test_new_private_edge_fails_exact_ledger_comparison( boundary.validate(ROOT, REGISTRY, LEDGER, AUTH_LEDGER) +def test_owner_adapter_may_bind_only_its_own_private_implementation( + tmp_path: Path, +) -> None: + """Owner adapters are composition; cross-owner private imports remain debt.""" + _registry(tmp_path / "registry.json") + _write( + tmp_path / "backend/app/adapters/tasks/__init__.py", + "from app.modules.tasks.repository import TaskRepository\n" + "from app.modules.projects.repository import ProjectRepository\n", + ) + private, _, _ = boundary.scan( + tmp_path, boundary.load_registry(tmp_path / "registry.json") + ) + assert private == { + boundary.PrivateEdge( + "backend/app/adapters/tasks/__init__.py", + "projects", + "app.modules.projects.repository", + "WS-ARCH-001-03", + ) + } + + +def test_non_root_adapter_keeps_same_owner_private_debt_visible( + tmp_path: Path, +) -> None: + """Only the exact owner composition root receives the wiring exemption.""" + _registry(tmp_path / "registry.json") + _write( + tmp_path / "backend/app/adapters/tasks/worker.py", + "from app.modules.tasks.repository import TaskRepository\n", + ) + private, _, _ = boundary.scan( + tmp_path, boundary.load_registry(tmp_path / "registry.json") + ) + assert private == { + boundary.PrivateEdge( + "backend/app/adapters/tasks/worker.py", + "tasks", + "app.modules.tasks.repository", + "WS-ARCH-001-03", + ) + } + + def test_import_from_modules_package_resolves_registered_alias(tmp_path: Path) -> None: """Package-level module aliases cannot disappear from dependency scanning.""" _registry(tmp_path / "registry.json") @@ -416,12 +461,22 @@ def test_initial_ledgers_capture_high_risk_application_edges() -> None: assert ( "backend/app/interfaces/artifact_operations.py", "app.modules.checkers.pre_submit_execution", - ) in actual + ) not in actual + assert not any( + source == "backend/app/adapters/artifacts/__init__.py" + and target.startswith("app.modules.artifacts.") + for source, target in actual + ) assert any( source.startswith("backend/app/adapters/artifacts/") + and source != "backend/app/adapters/artifacts/__init__.py" and target.startswith("app.modules.artifacts.") for source, target in actual ) + assert ( + "backend/app/adapters/artifacts/__init__.py", + "app.modules.actors.service_identities", + ) in actual assert any( source.startswith("backend/app/workers/") and target.startswith("app.modules.projects.") @@ -475,6 +530,25 @@ def test_auth_ledger_and_general_view_divergence_fails_closed( boundary.validate(ROOT, REGISTRY, LEDGER, AUTH_LEDGER) +def test_authorization_adapter_root_is_present_in_canonical_auth_view( + tmp_path: Path, +) -> None: + """Both scanners retain AUTH-private imports from its exact adapter root.""" + _registry(tmp_path / "registry.json") + source = tmp_path / "backend/app/adapters/authorization/__init__.py" + _write(source, "import app.modules.authorization.runtime\n") + registry = boundary.load_registry(tmp_path / "registry.json") + + _, _, actual_auth = boundary.scan(tmp_path, registry) + + assert actual_auth == { + boundary.authorization_boundary.ImportEdge( + "backend/app/adapters/authorization/__init__.py", + "app.modules.authorization.runtime", + ) + } + + @pytest.mark.parametrize( "source", ( diff --git a/backend/tests/pre_submit_test_helpers.py b/backend/tests/pre_submit_test_helpers.py new file mode 100644 index 000000000..46ba1fc2f --- /dev/null +++ b/backend/tests/pre_submit_test_helpers.py @@ -0,0 +1,71 @@ +"""Shared dependency-safe construction for focused pre-submit tests.""" + +from uuid import UUID, uuid4 + +from app.adapters.checkers import PreSubmitCheckerExecutionAdapter +from app.modules.artifacts.api import SubmissionBundlePreparationRequest +from app.modules.artifacts.submission_materialization import ( + PreparedBundleMaterializationService, + PreparedBundlePreSubmitEvidenceService, +) +from app.modules.authorization.api import ActorIdentityFacts, ActorKind +from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository +from app.modules.tasks.repository import TaskRepository + + +def checker_execution(inspector, catalogue) -> PreSubmitCheckerExecutionAdapter: + """Build the owner adapter used by ART materialization tests.""" + return PreSubmitCheckerExecutionAdapter( + archive_inspector=inspector, + catalogue=catalogue, + ) + + +def submission_preparation_request( + request, + *, + actor_profile_id: UUID, + identity_link_id: UUID, +) -> SubmissionBundlePreparationRequest: + """Project one private materialization fixture into the public ART request.""" + return SubmissionBundlePreparationRequest( + actor=ActorIdentityFacts( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + actor_kind=ActorKind.HUMAN, + ), + request_id=uuid4(), + correlation_id=uuid4(), + task_id=request.task_id, + assignment_id=request.assignment_id, + predecessor_submission_id=None, + idempotency_key=uuid4(), + summary=request.packet.summary, + contributor_attestation=request.packet.contributor_attestation, + media_type="application/zip", + byte_source=_empty_bytes(), + ) + + +async def _empty_bytes(): + if False: # pragma: no cover - preserve the async-iterable request shape + yield b"" + + +def evidence_workflow( + *, session, preparation, inspector, catalogue, materialization_authorization, + preparation_authorization, +) -> PreparedBundlePreSubmitEvidenceService: + """Compose the exact public-port evidence workflow for database proof.""" + return PreparedBundlePreSubmitEvidenceService( + session=session, + materialization=PreparedBundleMaterializationService( + authorization=materialization_authorization, + preparation=preparation, + checker_execution=checker_execution(inspector, catalogue), + storage_scheme="s3", + ), + preparation_authorization=preparation_authorization, + task_contexts=TaskRepository(session), + project_contexts=ProjectLockedPolicyRepository(session), + ) diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index f1548906b..eeb940cad 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -13,12 +13,14 @@ BACKEND_ROOT = Path(__file__).parents[1] APP_ROOT = BACKEND_ROOT / "app" ARTIFACT_OPERATIONS = APP_ROOT / "interfaces" / "artifact_operations.py" +SUBMISSION_PREPARATION_API = ( + APP_ROOT / "modules" / "artifacts" / "api" / "submission_preparation.py" +) COMPOSITION_ROOT = APP_ROOT / "adapters" / "artifacts" / "__init__.py" S3_ADAPTER_MODULE = APP_ROOT / "adapters" / "artifacts" / "s3_compatible.py" CLOSED_PORTS = { "GuideArtifactIngestCommand", "GuideArtifactIngestPort", - "SubmissionBundlePreparationPort", "ArtifactBindingPort", "ArtifactMaterializationPort", "CheckerArtifactOutputPort", @@ -32,8 +34,6 @@ "GuideSourceMaterializationRequest", "SubmissionBindingRequest", "CheckerOutputBindingRequest", - "SubmissionBundlePreparationRequest", - "PreparedBundleMaterializationRequest", "BindingMaterializationRequest", "CheckerOutputArtifactRequest", "ArtifactRecoveryRequest", @@ -58,7 +58,6 @@ "ArtifactRecoveryRequest", "GuideSufficiencyMaterialRequest", "GuideSourceMaterializationRequest", - "SubmissionBundlePreparationRequest", } PREPARED_HANDLE_FORBIDDEN_ROOTS = ( APP_ROOT / "adapters", @@ -515,14 +514,12 @@ def test_durable_artifact_mutation_ports_require_process_local_prepared_authorit expected_methods = { "GuideArtifactIngestPort": {"ingest"}, - "SubmissionBundlePreparationPort": {"prepare"}, "ArtifactBindingPort": { "bind_guide_source", "bind_submission", "bind_checker_output", }, "ArtifactMaterializationPort": { - "materialize_prepared_bundle", "materialize_guide_source", "materialize_bindings", }, @@ -530,11 +527,9 @@ def test_durable_artifact_mutation_ports_require_process_local_prepared_authorit } expected_request_by_method = { "ingest": "GuideArtifactIngestRequest", - "prepare": "SubmissionBundlePreparationRequest", "bind_guide_source": "GuideSourceBindingRequest", "bind_submission": "SubmissionBindingRequest", "bind_checker_output": "CheckerOutputBindingRequest", - "materialize_prepared_bundle": "PreparedBundleMaterializationRequest", "materialize_guide_source": "GuideSourceMaterializationRequest", "materialize_bindings": "BindingMaterializationRequest", "store": "CheckerOutputArtifactRequest", @@ -567,7 +562,7 @@ def test_durable_artifact_mutation_ports_require_process_local_prepared_authorit def test_submission_preparation_http_request_never_carries_prepared_authority() -> None: - tree = _tree(ARTIFACT_OPERATIONS) + tree = _tree(SUBMISSION_PREPARATION_API) request_class = next( node for node in tree.body @@ -579,7 +574,9 @@ def test_submission_preparation_http_request_never_carries_prepared_authority() if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) } assert "prepared_authorization" not in fields - assert fields["authorization_context"] == {"AuthorizationContext"} + assert fields["actor"] == {"ActorIdentityFacts"} + assert fields["request_id"] == {"UUID"} + assert fields["correlation_id"] == {"UUID"} assert fields["idempotency_key"] == {"UUID"} diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 9093a29fb..b63370aeb 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -6303,21 +6303,10 @@ def close(self) -> None: ) with pytest.raises(ArtifactAuthorityDeniedError, match="invalid"): await authority.consume( - service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, - action_id=ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, prepared_authorization=handle, facts=replace(facts, **{field_name: changed}), ) - with pytest.raises(ArtifactAuthorityDeniedError, match="invalid"): - await authority.consume( - service_identity=ServiceIdentity.ARTIFACT_GUIDE_READER, - action_id=ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - prepared_authorization=handle, - facts=facts, - ) await authority.consume( - service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, - action_id=ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, prepared_authorization=handle, facts=facts, ) @@ -6332,8 +6321,6 @@ def close(self) -> None: ) with pytest.raises(ArtifactAuthorityDeniedError, match="invalid"): await authority.consume( - service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, - action_id=ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, prepared_authorization=handle, facts=facts, ) diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index af89c2633..18c2be49e 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -207,6 +207,27 @@ def test_partition_accepts_only_the_v01_migration_tool_removals() -> None: ) +def test_partition_accepts_only_exact_02d_behavior_targets() -> None: + """02D registers its route and public contract without wildcard authority.""" + retained = "backend/app/core/config.py" + additions = { + "backend/app/api/routes/artifact_submissions.py", + "backend/app/modules/artifacts/api/submission_preparation.py", + } + trusted = _partition([retained]) + current = _partition(sorted({retained, *additions})) + + ownership._validate_additive_partition_transition(current, trusted) + + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition( + sorted({retained, *additions, "backend/app/api/routes/extra.py"}) + ), + trusted, + ) + + def test_partition_rejects_reordered_trusted_assignments( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index 5396c1b8e..7d4be09be 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -5,6 +5,7 @@ import asyncio from io import BytesIO from dataclasses import replace +import json from pathlib import Path import threading from types import SimpleNamespace @@ -20,7 +21,6 @@ from app.core.hashing import canonical_json_hash from app.core.config import Settings -from app.interfaces.artifact_operations import PreparedBundleMaterializationRequest from app.modules.artifacts.preparation import ( HARD_MAXIMUM_ARTIFACT_BYTES, ArtifactPreparationLimits, @@ -38,6 +38,7 @@ ArtifactVerificationReceipt, SubmissionBundleDurableIntent, ) +from app.modules.artifacts.pre_submit_evidence import PreSubmitEvidenceConflict, _validate_execution from app.modules.artifacts.service import ( ArtifactAdmissionRelationshipError, ArtifactAdmissionService, @@ -64,6 +65,7 @@ ) from app.modules.artifacts.submission_materialization import ( DenyPreSubmitMaterializationAuthorization, + PreparedBundleMaterializationRequest, PreparedBundlePreSubmitEvidenceService, PreparedBundleMaterializationService, ) @@ -80,11 +82,15 @@ ) from app.modules.checkers.pre_submit_execution import ( PreSubmissionResultStatus, - PreSubmissionInfrastructureUnavailable, SubmissionPacketView, - validate_pre_submission_execution_result, ) +from app.modules.checkers.api import PreSubmissionInfrastructureUnavailableError from tests.artifact_store_helpers import artifact_admission_limit_settings +from tests.pre_submit_test_helpers import ( + checker_execution as _CheckerExecution, + evidence_workflow, + submission_preparation_request, +) async def _bytes(value: bytes): @@ -117,14 +123,15 @@ async def test_evidence_workflow_requires_transaction_free_session() -> None: workflow = PreparedBundlePreSubmitEvidenceService( session=cast(Any, SimpleNamespace(in_transaction=lambda: True)), materialization=cast(Any, materialization), + preparation_authorization=cast(Any, SimpleNamespace()), + task_contexts=cast(Any, SimpleNamespace()), + project_contexts=cast(Any, SimpleNamespace()), ) with pytest.raises(RuntimeError, match="requires a transaction-free session"): await workflow.execute( cast(Any, object()), - actor_profile_id=uuid4(), - identity_link_id=uuid4(), - predecessor_submission_id=None, + preparation_request=cast(Any, object()), ) @@ -177,7 +184,7 @@ def _plan(catalogue): guide_id=uuid4(), guide_version=1, source_snapshot_id=uuid4(), - source_snapshot_hash="sha256:" + "1" * 64, + source_snapshot_hash=canonical_json_hash({}), effective_policy_id=uuid4(), effective_policy_hash=policy_hash, pre_submit_policy_id=uuid4(), @@ -233,8 +240,6 @@ async def prepare(self, *, facts, idempotency_key): async def consume(self, **values): self.facts = values["facts"] - self.action_id = values["action_id"] - self.service_identity = values["service_identity"] class _TestPreparedAuthorizationHandle: @@ -273,6 +278,7 @@ async def _request( assignment_id=uuid4(), submission_artifact_policy_id=plan.lineage.effective_policy_id, checker_policy_id=plan.lineage.pre_submit_policy_id, + predecessor_submission_version=None, prepared_artifact=prepared, effective_plan=plan, inspection=inspection, @@ -295,8 +301,7 @@ async def test_authority_denial_precedes_workspace_and_checker_access(tmp_path: service = PreparedBundleMaterializationService( authorization=DenyPreSubmitMaterializationAuthorization(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) @@ -329,8 +334,9 @@ async def forbidden_inspection(*_args, **_kwargs): service = PreparedBundleMaterializationService( authorization=DenyPreSubmitMaterializationAuthorization(), preparation=preparation, - archive_inspector=SubmissionArchiveInspector(SubmissionArchiveLimits()), - catalogue=catalogue, + checker_execution=_CheckerExecution( + SubmissionArchiveInspector(SubmissionArchiveLimits()), catalogue + ), storage_scheme="s3", ) @@ -373,13 +379,12 @@ async def test_manifest_drift_denies_before_authority_and_workspace(tmp_path: Pa service = PreparedBundleMaterializationService( authorization=authority, preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) with pytest.raises( - PreSubmissionInfrastructureUnavailable, + PreSubmissionInfrastructureUnavailableError, match="materialization_context_invalid", ): await service.materialize_prepared_bundle( @@ -401,8 +406,7 @@ async def test_two_stage_authority_uses_one_handle_and_exact_final_facts( service = PreparedBundleMaterializationService( authorization=authority, preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) handle = await service.prepare_authorization( @@ -441,6 +445,7 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( custody_triggers = ( ("projects", "project_creation_custody"), ("project_guides", "guide_mutation_product_custody"), + ("project_guides", "guide_lineage_lifecycle_guard"), ("guide_source_snapshots", "source_snapshot_product_custody"), ("submission_artifact_policies", "submission_policy_creation_custody"), ( @@ -448,13 +453,10 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( "effective_submission_policy_custody", ), ("pre_submit_checker_policies", "pre_submit_policy_custody"), - ("review_policies", "review_policy_mutation_custody"), - ("revision_policies", "revision_policy_mutation_custody"), + ("review_policies", "review_policy_mutation_custody"), ("revision_policies", "revision_policy_mutation_custody"), # noqa: E501 ) - blocked_prepared = replay_prepared = None - drift_prepared = None - denied_prepared = None - original_prepared_closed = False + blocked_prepared = replay_prepared = drift_prepared = denied_prepared = None + original_prepared_closed = bool() tables = ( "artifact_contents", "artifact_replicas", @@ -463,6 +465,8 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( "submissions", "checker_runs", "review_queue_entries", + "pre_submit_evidence_sets", + "pre_submit_evidence_results", ) try: async with engine.begin() as connection: @@ -476,6 +480,7 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( "submission_policy": str(uuid4()), "effective_policy": str(lineage.effective_policy_id), "effective_hash": lineage.effective_policy_hash, + "effective_body": json.dumps(_effective_policy()), "checker_body": json.dumps(compile_effective_project_submission_artifact_policy(_effective_policy(), lineage.effective_policy_hash).compiled_bundle), # noqa: E501 "checker_policy": str(lineage.pre_submit_policy_id), "checker_hash": lineage.pre_submit_policy_bundle_hash, "post_policy": str(uuid4()), @@ -509,7 +514,7 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( await connection.execute( text( "insert into projects (id,name,slug,status) values " - "(:project,'Evidence project',:project,'draft')" + "(:project,'Evidence project',:project,'active')" ), params, ) @@ -549,7 +554,7 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( "submission_artifact_policy_hash,lifecycle_status,merge_algorithm_version," "effective_policy,effective_policy_hash,created_by) values " "(:effective_policy,:project,:guide,'1',:snapshot,:snapshot_hash," - ":submission_policy,:effective_hash,'approved','1','{}'::json," + ":submission_policy,:effective_hash,'approved','1',cast(:effective_body as json)," ":effective_hash,'test')" ), params, @@ -562,7 +567,7 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( "lifecycle_status,compiler_version,compiled_bundle,compiled_bundle_hash," "checker_names,checker_configs,created_by) values " "(:checker_policy,:project,:guide,'1',:snapshot,:snapshot_hash," - ":effective_policy,:effective_hash,'compiled','1','{}'::json," + ":effective_policy,:effective_hash,'compiled','1',cast(:checker_body as json)," ":checker_hash,'[]'::json,'{}'::json,'test')" ), params, @@ -595,12 +600,13 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( ) await connection.execute( text( - "insert into revision_policies " + "with inserted_revision as (insert into revision_policies " "(id,project_id,guide_version,policy_generation,policy_hash," "semantics_status,max_revision_rounds,revision_deadline_hours," "allowed_resubmission_states) values " "(:revision_policy,:project,'1',1,:revision_policy_hash," - "'legacy_incomplete',1,24,'[]'::json)" + "'legacy_incomplete',1,24,'[]'::json) returning id) " + "update project_guides set status='active',selected_review_policy_id=:review_policy,selected_review_policy_generation=1,selected_review_policy_hash=:review_policy_hash,selected_revision_policy_id=:revision_policy,selected_revision_policy_generation=1,selected_revision_policy_hash=:revision_policy_hash where id=:guide" ), params, ) @@ -642,15 +648,19 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( } session_factory = async_sessionmaker(engine, expire_on_commit=False) async with session_factory() as session: - workflow = PreparedBundlePreSubmitEvidenceService( + preparation_authority = cast(Any, SimpleNamespace(revalidate=AsyncMock())) + workflow = evidence_workflow( session=session, - materialization=PreparedBundleMaterializationService( - authorization=_AllowAuthority(), - preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, - storage_scheme="s3", - ), + preparation=preparation, + inspector=inspector, + catalogue=catalogue, + materialization_authorization=_AllowAuthority(), + preparation_authorization=preparation_authority, + ) + preparation_request = submission_preparation_request( + request, + actor_profile_id=actor_id, + identity_link_id=identity_link_id, ) async def fresh_checked_bundle(): @@ -674,24 +684,18 @@ async def fresh_checked_bundle(): ) result = await workflow.execute( fresh_request, - actor_profile_id=actor_id, - identity_link_id=identity_link_id, - predecessor_submission_id=None, + preparation_request=preparation_request, ) assert result.pass_capability is not None return prepared, result first = await workflow.execute( request, - actor_profile_id=actor_id, - identity_link_id=identity_link_id, - predecessor_submission_id=None, + preparation_request=preparation_request, ) replay = await workflow.execute( request, - actor_profile_id=actor_id, - identity_link_id=identity_link_id, - predecessor_submission_id=None, + preparation_request=preparation_request, ) assert first.pass_capability is not None namespace = ArtifactStorageNamespaceSpec( @@ -1018,9 +1022,7 @@ async def publish_ready() -> str: ) blocked = await workflow.execute( blocked_request, - actor_profile_id=actor_id, - identity_link_id=identity_link_id, - predecessor_submission_id=None, + preparation_request=preparation_request, ) async with engine.begin() as connection: evidence_count = int( @@ -1121,14 +1123,14 @@ async def publish_ready() -> str: assert blocked.failure_audit["event_type"] == "pre_submission_check_failed" assert blocked.failure_audit["failed_count"] >= 1 assert "task.toml" not in repr(blocked.failure_audit) - assert evidence_count == 5 - assert result_count == 5 * len(request.effective_plan.entries) + assert (evidence_count, result_count) == (5, 5 * len(request.effective_plan.entries)) assert after == { **before, "artifact_contents": before["artifact_contents"] + 1, "artifact_replicas": before["artifact_replicas"] + 1, "artifact_put_attempts": before["artifact_put_attempts"] + 1, "submission_bundle_admissions": before["submission_bundle_admissions"] + 1, + "pre_submit_evidence_sets": evidence_count, "pre_submit_evidence_results": result_count, # noqa: E501 } @@ -1142,13 +1144,12 @@ async def test_materializer_rejects_policy_lineage_mismatch_before_authority( service = PreparedBundleMaterializationService( authorization=authority, preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) with pytest.raises( - PreSubmissionInfrastructureUnavailable, + PreSubmissionInfrastructureUnavailableError, match="pre_submission_materialization_context_invalid", ): await service.materialize_prepared_bundle( @@ -1170,8 +1171,7 @@ async def test_effective_executor_uses_plan_order_and_dispatches_project_rules( service = PreparedBundleMaterializationService( authorization=authority, preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) @@ -1182,13 +1182,14 @@ async def test_effective_executor_uses_plan_order_and_dispatches_project_rules( for entry in request.effective_plan.entries if entry.phase in set(PreSubmissionCheckerPhase) ] - assert [entry.definition.definition_id for entry in result.entries] == expected - assert any(entry.definition.definition_id.startswith("policy.") for entry in result.entries) - assert all(entry.status is PreSubmissionResultStatus.PASSED for entry in result.entries) + assert [entry.definition_id for entry in result.entries] == expected + assert any(entry.definition_id.startswith("policy.") for entry in result.entries) + assert all( + entry.checker_execution_status == PreSubmissionResultStatus.PASSED.value + for entry in result.entries + ) assert result.eligible is True assert authority.facts is not None - assert authority.action_id.value == "artifact.pre_submit.checker_input.materialize" - assert authority.service_identity.value == "workstream.artifact.materializer" assert authority.facts.task_id == request.task_id assert authority.facts.assignment_id == request.assignment_id assert authority.facts.project_id == request.effective_plan.lineage.project_id @@ -1217,25 +1218,26 @@ async def test_blocking_default_stops_later_dependency_without_review_decision( service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) - by_id = {entry.definition.definition_id: entry for entry in result.entries} + by_id = {entry.definition_id: entry for entry in result.entries} - assert by_id["artifact.sensitive_paths.high_confidence"].status is ( - PreSubmissionResultStatus.FAILED + assert ( + by_id["artifact.sensitive_paths.high_confidence"].checker_execution_status + == PreSubmissionResultStatus.FAILED.value ) - assert by_id["artifact.quality.placeholder_signal"].status is ( - PreSubmissionResultStatus.DEPENDENCY_NOT_RUN + assert ( + by_id["artifact.quality.placeholder_signal"].checker_execution_status + == PreSubmissionResultStatus.DEPENDENCY_NOT_RUN.value ) assert result.eligible is False assert all( value not in {"accept", "needs_revision", "reject"} for entry in result.entries - for value in (entry.status.value, entry.message_code, entry.failure_code) + for value in (entry.checker_execution_status, entry.message_code, entry.failure_code) if value is not None ) await request.prepared_artifact.close() @@ -1251,8 +1253,7 @@ async def test_disabled_advisory_is_explicit_and_not_skipped_success(tmp_path: P service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) @@ -1260,10 +1261,10 @@ async def test_disabled_advisory_is_explicit_and_not_skipped_success(tmp_path: P advisory = next( entry for entry in result.entries - if entry.definition.definition_id == "artifact.quality.placeholder_signal" + if entry.definition_id == "artifact.quality.placeholder_signal" ) - assert advisory.status is PreSubmissionResultStatus.ADVISORY_DISABLED + assert advisory.checker_execution_status == PreSubmissionResultStatus.ADVISORY_DISABLED.value assert result.eligible is True await request.prepared_artifact.close() manager.close() @@ -1279,8 +1280,7 @@ async def test_quality_warning_emits_only_a_bounded_category_count(tmp_path: Pat service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) @@ -1288,10 +1288,10 @@ async def test_quality_warning_emits_only_a_bounded_category_count(tmp_path: Pat warning = next( entry for entry in result.entries - if entry.definition.definition_id == "artifact.quality.placeholder_signal" + if entry.definition_id == "artifact.quality.placeholder_signal" ) - assert warning.status is PreSubmissionResultStatus.WARNING + assert warning.checker_execution_status == PreSubmissionResultStatus.WARNING.value assert warning.metadata == (("matched_category_count", 2),) assert result.eligible is True await request.prepared_artifact.close() @@ -1306,12 +1306,11 @@ async def test_forged_plan_identity_fails_closed_and_cleans_workspace(tmp_path: service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) - with pytest.raises(PreSubmissionInfrastructureUnavailable, match="plan_identity"): + with pytest.raises(PreSubmissionInfrastructureUnavailableError, match="plan_identity"): await service.materialize_prepared_bundle(request) assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] @@ -1374,12 +1373,11 @@ def __getattr__(self, name): service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=selected_catalogue, + checker_execution=_CheckerExecution(inspector, selected_catalogue), storage_scheme="s3", ) - with pytest.raises(PreSubmissionInfrastructureUnavailable, match=expected_message): + with pytest.raises(PreSubmissionInfrastructureUnavailableError, match=expected_message): await service.materialize_prepared_bundle(request) assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] @@ -1410,12 +1408,11 @@ async def test_disabled_mandatory_executor_state_fails_closed(tmp_path: Path) -> service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) - with pytest.raises(PreSubmissionInfrastructureUnavailable): + with pytest.raises(PreSubmissionInfrastructureUnavailableError): await service.materialize_prepared_bundle(request) assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] @@ -1457,8 +1454,7 @@ async def test_effective_execution_enforces_project_only_forbidden_rule(tmp_path service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) @@ -1466,11 +1462,9 @@ async def test_effective_execution_enforces_project_only_forbidden_rule(tmp_path assert result.eligible is False project_result = next( - entry - for entry in result.entries - if entry.definition.definition_id == "policy.artifact.forbid" + entry for entry in result.entries if entry.definition_id == "policy.artifact.forbid" ) - assert project_result.status is PreSubmissionResultStatus.FAILED + assert project_result.checker_execution_status == PreSubmissionResultStatus.FAILED.value await request.prepared_artifact.close() manager.close() @@ -1481,19 +1475,16 @@ async def test_effective_execution_enforces_server_owned_storage_scheme(tmp_path service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="local", ) result = await service.materialize_prepared_bundle(request) policy_result = next( - entry - for entry in result.entries - if entry.definition.definition_id == "policy.storage_scheme.enforce" + entry for entry in result.entries if entry.definition_id == "policy.storage_scheme.enforce" ) - assert policy_result.status is PreSubmissionResultStatus.FAILED + assert policy_result.checker_execution_status == PreSubmissionResultStatus.FAILED.value assert policy_result.message_code == "storage_scheme_not_allowed" await request.prepared_artifact.close() manager.close() @@ -1505,28 +1496,30 @@ async def test_canonical_result_validator_rejects_forged_definition(tmp_path: Pa service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) first = result.entries[0] forged = replace( result, - entries=( - replace( - first, - definition=replace(first.definition, public_name="caller-selected"), + checker_facts=replace( + result.checker_facts, + entries=( + replace( + first, + public_name="caller-selected", + ), + *result.entries[1:], ), - *result.entries[1:], ), ) with pytest.raises( - PreSubmissionInfrastructureUnavailable, + PreSubmitEvidenceConflict, match="pre_submission_result_context_invalid", ): - validate_pre_submission_execution_result(request.effective_plan, forged) + _validate_execution(request.effective_plan, forged) await request.prepared_artifact.close() manager.close() @@ -1546,8 +1539,7 @@ def forbidden(*_args, **_kwargs): service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) @@ -1578,8 +1570,7 @@ def blocking_process(self, reader, workspace): service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) @@ -1612,8 +1603,9 @@ def _project_file(self, *args, **kwargs): service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=_BlockingProjectionInspector(SubmissionArchiveLimits()), - catalogue=catalogue, + checker_execution=_CheckerExecution( + _BlockingProjectionInspector(SubmissionArchiveLimits()), catalogue + ), storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) @@ -1653,8 +1645,7 @@ def blocking_process(self, reader, workspace): service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=inspector, - catalogue=catalogue, + checker_execution=_CheckerExecution(inspector, catalogue), storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) @@ -1705,8 +1696,9 @@ def observed_execute(self, tree): service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, - archive_inspector=_BlockingSealInspector(SubmissionArchiveLimits()), - catalogue=catalogue, + checker_execution=_CheckerExecution( + _BlockingSealInspector(SubmissionArchiveLimits()), catalogue + ), storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) diff --git a/backend/tests/test_effective_pre_submit_execution.py b/backend/tests/test_effective_pre_submit_execution.py index 924955a29..1ab53c78a 100644 --- a/backend/tests/test_effective_pre_submit_execution.py +++ b/backend/tests/test_effective_pre_submit_execution.py @@ -13,12 +13,24 @@ load_locked_pre_submit_context, ) from app.modules.artifacts.pre_submit_evidence import ( + PreSubmitExecutionCustody, + PreSubmitExecutionResult, PreSubmitEvidenceConflict, PreSubmitEvidenceContext, PreSubmitEvidenceService, PersistedPreSubmitEvidence, pre_submit_failure_audit_payload, semantic_manifest_identity, + validate_predecessor_lineage, +) +from app.modules.tasks.api import ( + SubmissionPredecessorFacts, + TaskLockedProjectContextReferences, + TaskSubmissionContextFacts, +) +from app.modules.checkers.api import ( + PreSubmissionExecutionEntryFacts, + PreSubmissionExecutionFacts, ) from app.modules.checkers.compiler import ( PreSubmitCheckerCompilerError, @@ -81,6 +93,38 @@ def test_evidence_operation_identity_binds_every_custody_fact() -> None: assert identity != context.operation_identity(effective_plan_sha256=_sha("8")) +def test_post_byte_relock_rejects_advanced_predecessor_version() -> None: + predecessor_id = uuid4() + task_context = TaskSubmissionContextFacts( + task_id=uuid4(), + assignment_id=uuid4(), + contributor_id=uuid4(), + status="needs_revision", + kind="revision", + predecessor=SubmissionPredecessorFacts( + submission_id=predecessor_id, + version=2, + ), + locked_project_context=TaskLockedProjectContextReferences( + project_id=uuid4(), + guide_version="1", + source_snapshot_id=uuid4(), + source_snapshot_hash=_sha("1"), + effective_policy_id=uuid4(), + effective_policy_hash=_sha("2"), + pre_submit_policy_id=uuid4(), + pre_submit_policy_bundle_hash=_sha("3"), + ), + ) + + with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_locked_context_changed"): + validate_predecessor_lineage( + task_context, + predecessor_submission_id=predecessor_id, + predecessor_submission_version=1, + ) + + def test_semantic_manifest_identity_is_server_deterministic() -> None: assert semantic_manifest_identity(_sha("a")) == semantic_manifest_identity(_sha("a")) assert semantic_manifest_identity(_sha("a")) != semantic_manifest_identity(_sha("b")) @@ -89,7 +133,11 @@ def test_semantic_manifest_identity_is_server_deterministic() -> None: def test_pass_capability_is_generation_bound_and_single_use() -> None: evidence_set_id = uuid4() generation_id = uuid4() - capability = PreSubmitEvidenceService(SimpleNamespace())._mint_pass_capability( + capability = PreSubmitEvidenceService( + SimpleNamespace(), + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), + )._mint_pass_capability( evidence_set_id=evidence_set_id, prepared_generation_id=generation_id, predecessor_submission_id=None, @@ -348,39 +396,36 @@ def test_failure_audit_projection_is_bounded_and_path_free() -> None: task_id = uuid4() generation_id = uuid4() evidence = PersistedPreSubmitEvidence(uuid4(), _sha("1"), False) - execution = PreSubmissionExecutionResult( - plan_sha256=_sha("2"), - custody=PreSubmissionExecutionCustody( + execution = PreSubmitExecutionResult( + custody=PreSubmitExecutionCustody( prepared_generation_id=generation_id, archive_sha256=_sha("5"), archive_byte_count=1, semantic_manifest_sha256=_sha("6"), storage_scheme="s3", ), - eligible=False, - entries=( - PreSubmissionEntryResult( - schema_version="pre_submission_checker_result.v1", - definition=PreSubmissionResultDefinition( + checker_facts=PreSubmissionExecutionFacts( + plan_sha256=_sha("2"), + eligible=False, + entries=( + PreSubmissionExecutionEntryFacts( dispatch_authority="workstream.pre_submission_checker_catalogue", definition_id="policy.file.require", definition_version="v1", public_name="check_required_files", - source="locked_effective_project_submission_artifact_policy", - ), - policy_trace=PreSubmissionResultPolicyTrace( + policy_source="locked_effective_project_submission_artifact_policy", effective_plan_sha256=_sha("2"), rule_instance_id=_sha("3"), locked_policy_sha256=_sha("4"), + phase="project_policy", + order=10, + classification="mandatory_accountability", + severity="blocking", + checker_execution_status="failed", + failure_code="pre_submission_checker_failed", + message_code="required_file_missing", + metadata=(("finding_count", 1),), ), - phase="project_policy", - order=10, - classification="mandatory_accountability", - severity="blocking", - status=PreSubmissionResultStatus.FAILED, - failure_code="pre_submission_checker_failed", - message_code="required_file_missing", - metadata=(("finding_count", 1),), ), ), ) @@ -524,3 +569,26 @@ class _ForgedStatus: match="pre_submission_result_context_invalid", ): validate_pre_submission_execution_result(plan, replace(forged, eligible=1)) # type: ignore[arg-type] + + for metadata in ( + (("finding_count",),), + ((["finding_count"], 1),), + (("unknown_count", 1),), + (("finding_count", 1), ("finding_count", 2)), + (("finding_count", "1"),), + (("finding_count", -1),), + ): + malformed_entry = replace( + forged.entries[0], + failure_code=None, + metadata=metadata, # type: ignore[arg-type] + ) + malformed = replace( + forged, + entries=(malformed_entry, *forged.entries[1:]), + ) + with pytest.raises( + PreSubmissionInfrastructureUnavailable, + match="pre_submission_result_context_invalid", + ): + validate_pre_submission_execution_result(plan, malformed) diff --git a/backend/tests/test_pre_submit_evidence_relock.py b/backend/tests/test_pre_submit_evidence_relock.py new file mode 100644 index 000000000..dfe4fe48b --- /dev/null +++ b/backend/tests/test_pre_submit_evidence_relock.py @@ -0,0 +1,69 @@ +"""Focused proof for post-byte pre-submit evidence relocking.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.modules.artifacts import pre_submit_evidence +from app.modules.artifacts.pre_submit_evidence import ( + PreSubmitEvidenceConflict, + PreSubmitEvidenceService, +) +from app.modules.tasks.api import TaskSubmissionContextUnavailable + + +@pytest.mark.asyncio +async def test_stale_task_relock_denies_before_evidence_or_pass_capability( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A post-byte TASK drift cannot persist evidence or mint continuation.""" + transaction = SimpleNamespace(is_active=True) + session = SimpleNamespace( + sync_session=SimpleNamespace(get_transaction=lambda: transaction), + in_nested_transaction=lambda: False, + ) + task_contexts = SimpleNamespace( + lock_submission_context=AsyncMock( + side_effect=TaskSubmissionContextUnavailable( + "task_submission_context_invalid" + ) + ) + ) + project_contexts = SimpleNamespace(lock_locked_policy_context=AsyncMock()) + service = PreSubmitEvidenceService( + session, # type: ignore[arg-type] + task_contexts=task_contexts, + project_contexts=project_contexts, + ) + service._repository.persist = AsyncMock() # type: ignore[method-assign] + monkeypatch.setattr(pre_submit_evidence, "_validate_execution", lambda *_: None) + custody = SimpleNamespace( + prepared_generation_id="generation", + archive_sha256="archive", + archive_byte_count=1, + semantic_manifest_sha256="manifest", + ) + request = SimpleNamespace( + plan=object(), + execution=SimpleNamespace(custody=custody), + prepared_generation_id="generation", + archive_sha256="archive", + archive_byte_count=1, + semantic_manifest_sha256="manifest", + task_id="task", + assignment_id="assignment", + actor_profile_id="actor", + predecessor_submission_id=None, + ) + + with pytest.raises( + PreSubmitEvidenceConflict, + match="pre_submit_locked_context_changed", + ): + await service.persist(request) # type: ignore[arg-type] + + task_contexts.lock_submission_context.assert_awaited_once() + project_contexts.lock_locked_policy_context.assert_not_awaited() + service._repository.persist.assert_not_awaited() # type: ignore[union-attr] + assert service._live_pass_bindings == set() diff --git a/backend/tests/test_submission_bundle_admission.py b/backend/tests/test_submission_bundle_admission.py index 164c70981..f88f2b023 100644 --- a/backend/tests/test_submission_bundle_admission.py +++ b/backend/tests/test_submission_bundle_admission.py @@ -13,18 +13,30 @@ from fastapi import HTTPException from starlette.requests import Request +import app.adapters.artifacts as artifact_adapters import app.modules.artifacts.submission_admission as submission_admission_module +from app.core.config import Settings from app.modules.artifacts.pre_submit_evidence import ( PreSubmitEvidenceConflict, PreSubmitEvidenceService, PreSubmitPassCapability, ) +from app.modules.artifacts.api import ( + SubmissionBundlePreparationRejected, + SubmissionBundlePreparationRequest, + SubmissionBundlePreparationResult, + SubmissionBundlePreparationUnavailable, +) from app.modules.artifacts.preparation import ArtifactPreparationService, ArtifactScratchManager from app.modules.artifacts.schemas import ( ArtifactAdmissionResult, ArtifactAuthorityDeniedError, SubmissionBundleArtifactAdmissionRequest, ) +from app.interfaces.artifacts import ( + ArtifactConfigurationError, + ArtifactProviderLiveProofRequiredError, +) from app.modules.artifacts.submission_authorization import ( DenySubmissionBundlePreparedAuthorization, DenySubmissionBundlePreparationAuthorization, @@ -34,6 +46,9 @@ SubmissionBundleDurablePutResult, SubmissionBundleDurablePutService, ) +from app.modules.artifacts.submission_materialization import ( + PreparedBundlePreSubmitEvidenceService, +) from app.modules.artifacts.submission_admission import ( SubmissionBundleAdmissionPublicationError, SubmissionBundleAdmissionPublisher, @@ -42,36 +57,197 @@ from app.modules.artifacts.submission_custody import SubmissionBundlePreparedCustody from app.modules.artifacts.submission_admission import ( PreparedSubmissionBundlePreparationCommand, - SubmissionBundlePreparationRejected, - SubmissionBundlePreparationResult, -) -from app.interfaces.artifact_operations import SubmissionBundlePreparationRequest -from app.modules.authorization.runtime import ( - ActorKind, - ActorStatus, - HumanAuthorizationContext, - IdentityLinkStatus, ) +from app.modules.authorization.api import ActorIdentityFacts, ActorKind from app.modules.authorization.prepared import PreparedAuthorizationHandle from tests.artifact_store_helpers import artifact_byte_stream, artifact_preparation_limits +from tests.artifact_store_helpers import artifact_admission_limit_settings from app.main import create_app -from app.modules.tasks.router import ( - _require_ascii_submission_packet_headers, - prepare_submission_bundle, - router as tasks_router, -) -from app.modules.tasks.pre_submit_context import PreSubmitLockedContextInvalid +from app.api.routes.artifact_submissions import prepare_submission_bundle, router as submission_router +from app.modules.artifacts.submission_admission import validate_submission_packet_headers + + +def _actor() -> ActorIdentityFacts: + return ActorIdentityFacts( + actor_profile_id=uuid4(), + identity_link_id=uuid4(), + actor_kind=ActorKind.HUMAN, + ) + + +def _preparation_request( + *, byte_source, media_type: str = "application/zip", summary: str = "summary" +): + return SubmissionBundlePreparationRequest( + actor=_actor(), + request_id=uuid4(), + correlation_id=uuid4(), + task_id=uuid4(), + assignment_id=uuid4(), + predecessor_submission_id=None, + idempotency_key=uuid4(), + summary=summary, + contributor_attestation="attestation", + media_type=media_type, + byte_source=byte_source, + ) def _sha(character: str) -> str: return "sha256:" + character * 64 +@pytest.mark.asyncio +async def test_artifact_adapter_preserves_public_actor_facts() -> None: + actor = _actor() + + assert await artifact_adapters.get_submission_bundle_preparation_actor(actor) is actor + + +@pytest.mark.asyncio +async def test_artifact_adapter_default_preparation_authority_denies() -> None: + authority = artifact_adapters.get_submission_bundle_preparation_authorization() + request = _preparation_request(byte_source=object()) + + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.preflight(request=request) + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.revalidate(request=request) + with pytest.raises(ArtifactAuthorityDeniedError): + authority.transaction() + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.prepare_final(request=request) + authority.close() + + +@pytest.mark.asyncio +async def test_artifact_adapter_stale_scratch_cleanup_closes_manager( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = SimpleNamespace(cleanup_stale=AsyncMock(return_value=3), close=Mock()) + monkeypatch.setattr( + artifact_adapters, + "create_artifact_scratch_manager", + lambda _settings: manager, + ) + + assert await artifact_adapters.cleanup_stale_artifact_scratch(Settings()) == 3 + manager.cleanup_stale.assert_awaited_once_with() + manager.close.assert_called_once_with() + + +def test_artifact_adapter_maps_preparation_limits_from_settings() -> None: + settings = Settings() + + preparation = artifact_adapters.artifact_preparation_limits(settings) + archive = artifact_adapters.submission_archive_limits(settings) + + assert preparation.maximum_source_bytes == settings.artifact_maximum_bytes + assert preparation.maximum_files == settings.artifact_scratch_maximum_files + assert preparation.stream_buffer_bytes == settings.artifact_stream_buffer_bytes + assert archive.maximum_entries == settings.artifact_submission_zip_maximum_entries + assert archive.maximum_entry_bytes == settings.artifact_submission_zip_maximum_entry_bytes + assert ( + archive.maximum_compression_ratio + == settings.artifact_submission_zip_maximum_compression_ratio + ) + + +def test_artifact_adapter_rejects_unproven_aws_runtime() -> None: + settings = SimpleNamespace( + artifact_store_backend="s3_compatible", artifact_s3_provider_profile="aws_s3" + ) + + with pytest.raises(ArtifactProviderLiveProofRequiredError): + artifact_adapters.require_artifact_runtime_eligible(settings) # type: ignore[arg-type] + + +def test_artifact_adapter_requires_configured_scratch_root() -> None: + settings = Settings() + settings.artifact_scratch_root = None + + with pytest.raises(ArtifactConfigurationError): + artifact_adapters.create_artifact_scratch_manager(settings) + + +def test_artifact_adapter_builds_configured_local_bootstrap(tmp_path) -> None: + root = tmp_path / "objects" + root.mkdir(mode=0o700) + settings = Settings( + **artifact_admission_limit_settings(), + environment="test", + artifact_store_backend="local", + artifact_local_root=root, + artifact_scratch_root=tmp_path / "scratch", + artifact_scratch_minimum_free_bytes=0, + ) + + bootstrap = artifact_adapters.create_artifact_store_bootstrap(settings) + + try: + assert bootstrap.identity.provider_key == "local" + assert bootstrap.namespace_identity.provider_profile == "local-v2" + finally: + bootstrap.close() + + +def test_artifact_adapter_composes_submission_command_from_owner_ports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request_id = uuid4() + correlation_id = uuid4() + request = Request( + { + "type": "http", + "method": "POST", + "path": "/hidden", + "headers": [], + "state": { + "request_id": str(request_id), + "correlation_id": str(correlation_id), + }, + } + ) + request.scope["app"] = SimpleNamespace( + state=SimpleNamespace( + settings=Settings(), + pre_submission_checker_catalogue=object(), + ) + ) + session = object() + internal_authority = object() + authority = artifact_adapters.get_submission_bundle_preparation_authorization() + task_contexts = object() + project_contexts = object() + monkeypatch.setattr( + artifact_adapters, "task_submission_context_port", Mock(return_value=task_contexts) + ) + monkeypatch.setattr( + artifact_adapters, + "project_locked_policy_context_port", + Mock(return_value=project_contexts), + ) + + command = artifact_adapters.get_submission_bundle_preparation_command( + request, + session, # type: ignore[arg-type] + internal_authority, # type: ignore[arg-type] + authority, + ) + + assert command._session is session + assert command._authority is authority + assert command._task_contexts is task_contexts + assert command._project_contexts is project_contexts + artifact_adapters.task_submission_context_port.assert_called_once_with(session) + artifact_adapters.project_locked_policy_context_port.assert_called_once_with(session) + + def test_submission_bundle_preparation_route_is_hidden() -> None: app = create_app() route = next( route - for route in tasks_router.routes + for route in submission_router.routes if getattr(route, "name", None) == "prepare_submission_bundle" ) assert route.include_in_schema is False @@ -83,26 +259,24 @@ def test_submission_bundle_preparation_route_is_hidden() -> None: def test_submission_packet_headers_reject_non_ascii() -> None: - _require_ascii_submission_packet_headers("plain summary", "plain attestation") - with pytest.raises(HTTPException) as failure: - _require_ascii_submission_packet_headers("caf\N{LATIN SMALL LETTER E WITH ACUTE}", "ok") - assert failure.value.status_code == 422 - assert failure.value.detail == "submission_bundle_packet_header_encoding_invalid" + validate_submission_packet_headers("plain summary", "plain attestation") + with pytest.raises( + SubmissionBundlePreparationRejected, + match="submission_bundle_packet_header_encoding_invalid", + ): + validate_submission_packet_headers( + "caf\N{LATIN SMALL LETTER E WITH ACUTE}", "ok" + ) @pytest.mark.asyncio async def test_hidden_preparation_maps_locked_context_race_to_bounded_conflict() -> None: command = SimpleNamespace( - prepare=AsyncMock(side_effect=PreSubmitLockedContextInvalid("changed")) - ) - context = HumanAuthorizationContext( - actor_profile_id=uuid4(), - actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, - identity_link_id=uuid4(), - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=uuid4(), - correlation_id=uuid4(), + prepare=AsyncMock( + side_effect=SubmissionBundlePreparationRejected( + "submission_bundle_preparation_context_changed" + ) + ) ) request = Request( { @@ -117,7 +291,7 @@ async def test_hidden_preparation_maps_locked_context_race_to_bounded_conflict() await prepare_submission_bundle( task_id=str(uuid4()), request=request, - context=context, + actor=_actor(), command=command, assignment_id=str(uuid4()), idempotency_key=str(uuid4()), @@ -161,28 +335,15 @@ def runtime_factory(): command = PreparedSubmissionBundlePreparationCommand( session=SimpleNamespace(), authority=DenySubmissionBundlePreparationAuthorization(), + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), runtime_factory=runtime_factory, ) - with pytest.raises(ArtifactAuthorityDeniedError): + with pytest.raises(SubmissionBundlePreparationUnavailable): await command.prepare( - SubmissionBundlePreparationRequest( - authorization_context=HumanAuthorizationContext( - actor_profile_id=uuid4(), - actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, - identity_link_id=uuid4(), - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=uuid4(), - correlation_id=uuid4(), - ), - task_id=uuid4(), - assignment_id=uuid4(), - predecessor_submission_id=None, - idempotency_key=uuid4(), - summary="summary", - contributor_attestation="attestation", - media_type="application/zip", + _preparation_request( byte_source=bytes_source(), + summary="caf\N{LATIN SMALL LETTER E WITH ACUTE}", ) ) assert reads == 0 @@ -197,6 +358,8 @@ async def test_hidden_preparation_closes_authority_after_invalid_media_type() -> command = PreparedSubmissionBundlePreparationCommand( session=SimpleNamespace(), authority=authority, + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), runtime_factory=Mock(side_effect=AssertionError("runtime must stay closed")), ) with pytest.raises( @@ -204,29 +367,50 @@ async def test_hidden_preparation_closes_authority_after_invalid_media_type() -> match="submission_bundle_media_type_invalid", ): await command.prepare( - SubmissionBundlePreparationRequest( - authorization_context=HumanAuthorizationContext( - actor_profile_id=uuid4(), - actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, - identity_link_id=uuid4(), - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=uuid4(), - correlation_id=uuid4(), - ), - task_id=uuid4(), - assignment_id=uuid4(), - predecessor_submission_id=None, - idempotency_key=uuid4(), - summary="summary", - contributor_attestation="attestation", - media_type="application/json", - byte_source=artifact_byte_stream(b"{}"), + _preparation_request( + byte_source=artifact_byte_stream(b"{}"), media_type="application/json" ) ) authority.close.assert_called_once_with() +@pytest.mark.asyncio +async def test_post_byte_authority_denial_precedes_evidence_relock() -> None: + denial = ArtifactAuthorityDeniedError("submission bundle preparation is unavailable") + authority = SimpleNamespace(revalidate=AsyncMock(side_effect=denial)) + task_contexts = SimpleNamespace(lock_submission_context=AsyncMock()) + project_contexts = SimpleNamespace(lock_locked_policy_context=AsyncMock()) + session = SimpleNamespace( + in_transaction=lambda: False, + begin=_transaction, + execute=AsyncMock(), + ) + workflow = PreparedBundlePreSubmitEvidenceService( + session=session, + materialization=SimpleNamespace(), + preparation_authorization=authority, + task_contexts=task_contexts, + project_contexts=project_contexts, + ) + materialization_request = SimpleNamespace( + prepared_artifact=SimpleNamespace( + commitment=SimpleNamespace(sha256=_sha("1"), byte_count=1), + generation_id=uuid4(), + ), + ) + + with pytest.raises(ArtifactAuthorityDeniedError): + await workflow.persist( + materialization_request, + execution=object(), + preparation_request=object(), + ) + + authority.revalidate.assert_awaited_once() + task_contexts.lock_submission_context.assert_not_awaited() + project_contexts.lock_locked_policy_context.assert_not_awaited() + + @pytest.mark.asyncio async def test_existing_durable_preparation_projects_exact_ready_admission() -> None: attempt_id = uuid4() @@ -242,6 +426,8 @@ async def test_existing_durable_preparation_projects_exact_ready_admission() -> command = PreparedSubmissionBundlePreparationCommand( session=session, authority=SimpleNamespace(), + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), runtime_factory=Mock(), ) @@ -250,7 +436,7 @@ async def test_existing_durable_preparation_projects_exact_ready_admission() -> assert result == SubmissionBundlePreparationResult( put_attempt_id=attempt_id, admission_id=admission_id, - status="ready", + submission_bundle_preparation_status="ready", replayed=True, ) @@ -264,7 +450,7 @@ async def test_hidden_preparation_replays_persisted_checked_custody(monkeypatch) expected = SubmissionBundlePreparationResult( put_attempt_id=uuid4(), admission_id=uuid4(), - status="ready", + submission_bundle_preparation_status="ready", replayed=True, ) locked = SimpleNamespace(effective_policy_id=uuid4(), pre_submit_policy_id=uuid4()) @@ -294,16 +480,6 @@ async def test_hidden_preparation_replays_persisted_checked_custody(monkeypatch) async def runtime_factory(): yield runtime - monkeypatch.setattr( - submission_admission_module, - "load_locked_pre_submit_context", - AsyncMock(return_value=locked), - ) - monkeypatch.setattr( - submission_admission_module, - "compile_locked_pre_submit_plan", - Mock(return_value=object()), - ) monkeypatch.setattr( submission_admission_module, "build_submission_manifest", @@ -318,22 +494,26 @@ async def runtime_factory(): command = PreparedSubmissionBundlePreparationCommand( session=SimpleNamespace(begin=_transaction), authority=authority, + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), runtime_factory=runtime_factory, ) + command._lock_context = AsyncMock( + return_value=(SimpleNamespace(predecessor=None), locked) + ) + command._compile_plan = Mock(return_value=object()) command._load_predecessor = AsyncMock(return_value=None) command._existing_durable_result = AsyncMock(return_value=expected) result = await command.prepare( SubmissionBundlePreparationRequest( - authorization_context=HumanAuthorizationContext( + actor=ActorIdentityFacts( actor_profile_id=actor_id, - actor_kind=ActorKind.HUMAN, - actor_status=ActorStatus.ACTIVE, identity_link_id=uuid4(), - identity_link_status=IdentityLinkStatus.ACTIVE, - request_id=uuid4(), - correlation_id=uuid4(), + actor_kind=ActorKind.HUMAN, ), + request_id=uuid4(), + correlation_id=uuid4(), task_id=task_id, assignment_id=assignment_id, predecessor_submission_id=None, @@ -353,28 +533,58 @@ async def runtime_factory(): authority.close.assert_called_once_with() -def test_durable_put_result_projects_without_losing_replay_state() -> None: +@pytest.mark.parametrize( + "durable_status", + ( + "prepared", + "put_in_flight", + "object_confirmed", + "stored_pending_verification", + "observed_confirmed", + "acknowledgement_unknown", + "provider_unavailable", + "conflict", + "integrity_mismatch", + "missing", + "absent_replay_required", + "stale", + "ready", + ), +) +def test_durable_put_result_projects_every_closed_preparation_status( + durable_status: str, +) -> None: durable = SubmissionBundleDurablePutResult( put_attempt_id=uuid4(), pre_submit_evidence_set_id=uuid4(), operation_identity=_sha("9"), admission_id=None, - status="prepared", + status=durable_status, replayed=True, ) - assert PreparedSubmissionBundlePreparationCommand._result(durable) == ( SubmissionBundlePreparationResult( put_attempt_id=durable.put_attempt_id, admission_id=None, - status="prepared", + submission_bundle_preparation_status=durable_status, replayed=True, ) ) +def test_post_byte_locked_context_conflict_maps_to_public_race_code() -> None: + conflict = PreSubmitEvidenceConflict("pre_submit_locked_context_changed") + assert ( + PreparedSubmissionBundlePreparationCommand._evidence_conflict_code(conflict) + == "submission_bundle_preparation_context_changed" + ) + def _capability(prepared, evidence_set_id): - service = PreSubmitEvidenceService(SimpleNamespace()) + service = PreSubmitEvidenceService( + SimpleNamespace(), + task_contexts=SimpleNamespace(), + project_contexts=SimpleNamespace(), + ) return service._mint_pass_capability( evidence_set_id=evidence_set_id, prepared_generation_id=prepared.generation_id, diff --git a/docs/architecture_lockdown.md b/docs/architecture_lockdown.md index 3638d6901..2beb112c5 100644 --- a/docs/architecture_lockdown.md +++ b/docs/architecture_lockdown.md @@ -20,8 +20,12 @@ authentication adapter, not the product boundary. The backend is a modular monolith governed by the canonical module map and public-API dependency rules in [`../.agent-loop/policies/architecture-boundaries.md`](../.agent-loop/policies/architecture-boundaries.md). -Cross-module runtime imports use only the target module's typed `api` package; -the composition root alone wires concrete implementations. +Cross-module runtime imports use only the target module's typed `api` package. +Concrete implementations are wired only by the application composition root or +the exact same-owner adapter root `backend/app/adapters//__init__.py`. +That narrow adapter-root rule permits construction of the owner's public ports; +it does not permit nested adapter files or any adapter to import another +module's private implementation. The TASKS public boundary exposes immutable task, assignment, immediate Submission predecessor, and locked project-context reference selectors through diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index 5150be060..02842e463 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -270,3 +270,8 @@ edges, public API leaks, unknown modules, cyclic public dependencies, dynamic-import hiding, and agreement with the WS-AUTH-003 ledger. The edge inventory is temporary recovery evidence; do not add an edge to make a feature pass. Remove the dependency through the owning module's typed public API. +The sole non-ledgered owner-private composition exception is the exact +`backend/app/adapters//__init__.py` file importing that same owner's +private implementation to construct typed public ports. Nested adapter files +and cross-owner private imports remain scanned and must be repaired or remain +exact protected-base debt. diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index 042161cd5..44560a3d2 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -351,11 +351,10 @@ Product modules receive only these narrow asynchronous capabilities: ```text GuideArtifactIngestPort.ingest(GuideArtifactIngestRequest) -SubmissionBundlePreparationPort.prepare(SubmissionBundlePreparationRequest) +SubmissionBundlePreparationCommand.prepare(SubmissionBundlePreparationRequest) ArtifactBindingPort.bind_guide_source(GuideSourceBindingRequest) ArtifactBindingPort.bind_submission(SubmissionBindingRequest) ArtifactBindingPort.bind_checker_output(CheckerOutputBindingRequest) -ArtifactMaterializationPort.materialize_prepared_bundle(PreparedBundleMaterializationRequest) ArtifactMaterializationPort.materialize_bindings(BindingMaterializationRequest) CheckerArtifactOutputPort.store(CheckerOutputArtifactRequest) ArtifactOperatorReadPort.list_bindings/list_replicas/list_receipts/ @@ -365,8 +364,10 @@ ArtifactOperatorRecoveryPort.retry_verification(ArtifactRecoveryRequest) Every protected durable mutation in this port family consumes one opaque, process-local `PreparedAuthorizationHandle` at its transaction boundary. The -hidden HTTP submission-bundle preparation request carries authenticated request -context rather than a prepared handle; ART obtains separate prepared handles +hidden HTTP submission-bundle preparation request carries immutable +`ActorIdentityFacts`, request and correlation IDs, contributor selectors, +packet metadata, and an asynchronous byte source rather than a prepared handle; +ART obtains separate prepared handles immediately before materialization and durable put intent. The typed methods fix their expected actions, and handles never enter route schemas, outbox/Celery payloads, provider interfaces, or serialized contracts. @@ -378,12 +379,18 @@ reference. `GuideSourceMaterializationRequest` contains an idempotency key and the exact guide, source, setup-generation, and binding identifiers; it contains no prepared handle. The materializer creates and consumes fresh fixed-reader authority inside the same root transaction that locks the canonical read facts. -`SubmissionBundlePreparationRequest` contains authenticated request context, -contributor task/assignment selectors, and one outer ZIP byte source. There is -no upload-session compatibility port. +`SubmissionBundlePreparationRequest` is exported only from +`app.modules.artifacts.api`. It contains authenticated actor/identity-link +facts, request and correlation IDs, contributor task/assignment/predecessor and +idempotency selectors, packet metadata, and one outer ZIP byte source. It +exposes neither raw authorization context nor a prepared handle. There is no +upload-session compatibility port. `PreparedBundleMaterializationRequest` is internal and process-local; it wraps -only prepared authority, the exact task/assignment context, the current -`PreparedArtifact` generation, and exact policy/checker selectors. +opaque prepared authority, the exact task/assignment context, the current +`PreparedArtifact` generation, the effective plan, inspection, manifest, +change gate, packet, and exact policy/checker selectors. It is not a +product capability port and may not cross a route, background execution, +provider, or module public boundary. `BindingMaterializationRequest` contains task/submission/checker-run context and immutable binding IDs. `CheckerOutputArtifactRequest` contains the fixed service's prepared authority, task/submission/checker-run IDs, logical role,