diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md index 3b5d38426..6b5b723dc 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md @@ -32,7 +32,7 @@ they cross multiple L1 boundaries. | `WS-ART-001-04A4` | Former early removal of the legacy independently invocable caller-owned submission-precheck route and contract. | L1 | Superseded by PLAN5; complete removal belongs to 05B | | `WS-ART-001-04B1` | Add the single versioned checker catalogue and compile one effective execution plan from platform defaults plus locked project policy. | L1 | Merged PR #276 | | `WS-ART-001-04B2` | Materialize the sealed manifest tree once and execute the mandatory platform/default catalogue phases. | L1 | Merged PR #282 | -| `WS-ART-001-04B3` | Execute locked project-policy rules through the same plan and persist one bounded immutable evidence set. | L1 | Active implementation | +| `WS-ART-001-04B3` | Execute locked project-policy rules through the same plan and persist one bounded immutable evidence set. | L1 | Merged PR #291 as `8f516e6d` | | `WS-ART-001-04C1` | Reauthorize and atomically persist capacity plus durable put intent, then write the checked ZIP once. | L1 | Proposed after XINT-06A | | `WS-ART-001-04C2` | Reuse verification/recovery to publish one capacity-charged ready admission and compose the hidden continuous endpoint. | L1 | Proposed after 04C1 | | `WS-ART-001-05A` | Atomically consume ready admission into one immutable Submission and binding under fresh human/service authority. | L1 | Proposed after XINT-05A | diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md index a5fbd2034..359364d0f 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md @@ -130,11 +130,11 @@ PR #282. It owns only fixed-service authorization before byte access, quota-reserved callback-scoped sealed ZIP projection, platform/default phase execution, bounded non-durable results, and cleanup. It performs no project-policy execution, durable evidence/admission/Submission write, route -exposure, provider I/O, or AUTH activation. `WS-ART-001-04B3` implementation -and internal L1 review are complete on its bounded branch. It executes the -project-policy continuation through that same plan and sealed tree and persists -one immutable platform-plus-project evidence set. Hosted PR gates, CodeRabbit, -human review, and merge remain pending. +exposure, provider I/O, or AUTH activation. `WS-ART-001-04B3` merged through +PR #291 as `8f516e6d`. It executes the project-policy continuation through that +same plan and sealed tree and persists one immutable platform-plus-project +evidence set. ART-04C1 remains stopped until AUTH `WS-XINT-002-06A` activates +the mandatory fixed pre-submit materializer. ## Gate diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md index 4481fb1e9..c16a131b5 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md @@ -45,7 +45,7 @@ mappings, and availability must remain identical. | `WS-XINT-002-04B` | Active: `artifact.guide_source.read`, `artifact.guide_source.binding.create` | | `WS-XINT-002-04A` | Active: `artifact.guide_source.ingest` | | `WS-XINT-002-05A` | Planned: `artifact.submission_bundle.prepare` | -| `WS-XINT-002-06A` | Planned: `artifact.pre_submit.checker_input.materialize` | +| `WS-XINT-002-06A` | Active: `artifact.pre_submit.checker_input.materialize` | | `WS-XINT-002-05B` | Planned: `artifact.submission.binding.create` | | `WS-XINT-002-06B` | Planned: `artifact.post_submit.checker_input.materialize`, `artifact.checker_output.write`, `artifact.checker_output.binding.create` | | `WS-XINT-002-07A` | Planned: `artifact.review_packet.materialize` only | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 40cd81b2c..86fac8d83 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -70,7 +70,7 @@ stopped. | `WS-AUTH-001-12E` | Guide Sufficiency Mutation Cutover | L1 | Merged through PR #263 | | `WS-AUTH-001-12F` | Submission Artifact Policy Planning Parent | L1 | Split after failed L1 pre-start review; zero activation | | `WS-AUTH-001-12F1` | Submission Policy Authority Foundation | L1 | Merged through PR #286; zero activation | -| `WS-AUTH-001-12F2` | Manual Submission Policy Drafts | L1 | In progress after passed corrected pre-start review | +| `WS-AUTH-001-12F2` | Manual Submission Policy Drafts | L1 | Merged through PR #292 as `81f281bd` | | `WS-AUTH-001-12F3` | Fixed-Service Policy Derivation | L1 | Proposed after 12F2 | | `WS-AUTH-001-12F4` | Submission Policy Approval Chain | L1 | Proposed after 12F3 | | `WS-AUTH-001-12G` | Post-Submit Checker Policy Mutation Cutover | L1 | Proposed after 12F4 | @@ -92,7 +92,7 @@ feature manifest exists, then requires a separate explicit start. | `WS-AUTH-001-ART-02D-INTERNAL` | ART 02D Internal Action Activation | L1 | Feature-gated | | `WS-AUTH-001-ART-02D-OPERATOR` | ART 02D Operator Read/Status And Independently Evaluated Retry Activation | L1 | Feature-gated | | `WS-AUTH-001-ART-03` | ART 03 Guide Source Action Activation | L1 | Feature-gated | -| `WS-XINT-002-06A` | Pre-Submit Materialization Activation | L1 | After hidden ART-04B; before 05A | +| `WS-XINT-002-06A` | Pre-Submit Materialization Activation | L1 | After merged ART-04B3/AUTH-12F2; before ART-04C1 and 05A | | `WS-XINT-002-05A` | Submission Bundle Preparation Activation | L1 | Feature-gated on complete ART-04A1-04C2 hidden behavior and 06A | | `WS-XINT-002-05B` | Submission Binding Activation | L1 | Feature-gated on hidden ART-05A | | `WS-XINT-002-06B` | Post-Submit Materialization And Checker Output Activation | L1 | Feature-gated on ART-06A/06B | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index a1b10ab09..cb2f11f14 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -124,17 +124,15 @@ setup-run binding findings were repaired. ## Active implementation chunk -`WS-AUTH-001-12E`; the user started the current-main guide-sufficiency -authorization cutover after PR #257 merged REV-AUTH readiness. AUTH-12B, 12C, -and 12D are merged. XINT-003-02A/02B supplied the immutable policy lineage and -authorized mutation path that supersede the old 12D2 dependency. The 12E -contract and preimplementation review are complete. Runtime implementation is -under deterministic proof; its three actions become active only with this -bounded merge. +`WS-XINT-002-06A`; the user started the fixed pre-submit materializer +activation after ART-04B3 and AUTH-12F2 merged. This cross-initiative chunk +activates only `artifact.pre_submit.checker_input.materialize` before ART-04C1. +AUTH-12F3 remains the next AUTH-12 successor and resumes while ART proceeds +through 04C1/04C2 after 06A merges. ## Current review branch -`codex/ws-auth-001-12e-guide-sufficiency`. +`codex/ws-xint-002-06a-pre-submit-materialization`. ## Chunk status @@ -185,7 +183,7 @@ bounded merge. | `WS-AUTH-001-12E` | Merged | `codex/ws-auth-001-12e-guide-sufficiency` | #263 | Three guide-sufficiency actions plus fixed setup-service run PREP merged as `b510bc4f`. | | `WS-AUTH-001-12F` | Planning split | `codex/ws-auth-001-12f-submission-artifact-policy` | - | Combined contract failed required L1 pre-start review; parent now activates nothing and delegates to 12F1-12F4. | | `WS-AUTH-001-12F1` | Merged | `codex/ws-auth-001-12f1-submission-policy-foundation` | #286 | Submission-policy PREP, replay, provenance, and audit custody foundation merged as `5a4186cc`; zero activation. | -| `WS-AUTH-001-12F2` | In progress | `codex/ws-auth-001-12f2-manual-submission-policy` | - | Corrected contract passed all required L1 pre-start reviews; governed Project Manager append-only manual-draft create/update cutover is active. | +| `WS-AUTH-001-12F2` | Merged | `codex/ws-auth-001-12f2-manual-submission-policy` | #292 | Governed Project Manager append-only manual-draft create/update cutover merged as `81f281bd`. | | `WS-AUTH-001-12F3` | Proposed | - | - | Fixed setup-service derivation and asynchronous executor cutover. | | `WS-AUTH-001-12F4` | Proposed | - | - | Project Manager approval and atomic effective/pre-submit policy chain. | | `WS-AUTH-001-12G` | Proposed | - | - | Post-submit checker policy approval/correction cutover after 12F4. | diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md index 88860402f..de3a0c0a7 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md @@ -7,7 +7,7 @@ | `03` | Activate verifier, scheduler scan, and put resolver. | L1 | Merged | | `04A` | Activate Project Manager guide ingest. | L1 | Merged/active | | `04B` | Activate fixed-service guide binding and read. | L1 | Merged/active in PR #245 (`6babf81b`) | -| `06A` | Activate only pre-submit checker-input materialization. | L1 | Hidden ART-04B evidence; must precede 05A | +| `06A` | Activate only pre-submit checker-input materialization. | L1 | Merged ART-04B3/AUTH-12F2 evidence; must precede ART-04C1 and 05A | | `05A` | Activate initial contributor preparation and durable ready admission. | L1 | 06A plus ART-04A1-04C2 evidence | | `05B` | Activate fresh human Submission creation plus fixed binding/consumption. | L1 | 05A plus ART-05A/TASK evidence | | `05C` | Activate checker-remediation submission context. | L1 | 05B plus checker remediation evidence | diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md index 8e1a5667a..72304280b 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md @@ -1,6 +1,6 @@ # Chunk Contract: WS-XINT-002-06A — Pre-Submit Materialization Activation -Parent initiative: `WS-XINT-002` | Risk: L1 | Status: Proposed after ART-04B +Parent initiative: `WS-XINT-002` | Risk: L1 | Status: Active after merged ART-04B3 and AUTH-12F2 ## Goal @@ -9,8 +9,17 @@ preparation can become available. ## Allowed Files -AUTH catalogue/matrix/composition, ART authorization adapter/resource facts, -pre-submit checker materialization integration, focused tests/docs/CI evidence. +- `backend/app/modules/authorization/catalogue.py` +- `backend/app/modules/authorization/runtime.py` +- `backend/app/modules/authorization/kernel.py` +- `backend/app/modules/authorization/prepared.py` +- `backend/app/modules/audit/schemas.py` +- `backend/app/modules/artifacts/authorization.py` +- `backend/app/modules/artifacts/submission_materialization.py` +- focused authorization/materialization tests under `backend/tests/` +- canonical AUTH/ART/XINT status, chunk, specification, and review evidence +- hosted CI metadata only when required to run the existing gates; the gates + and coverage thresholds may not be weakened ## Not Allowed Changes @@ -22,10 +31,21 @@ bindings, human checker authority, generic artifact reads, or new ActionIds. - only `artifact.pre_submit.checker_input.materialize` changes availability; - only the fixed pre-submit materializer identity may prepare and consume it; - authority binds the process-local prepared-bundle/scratch generation, - task/project/guide/locked policy, archive/manifest, checker definition, + exact active assignment identity, task, project, + effective submission-artifact policy, pre-submit checker policy, plan and + catalogue hashes, archive digest and byte count, semantic-manifest hash, server-selected ArtifactStore storage scheme, request, session, and transaction facts; no durable admission exists yet; -- denial/replay/stale/cross-resource cases fail before scratch exposure; +- assignment currentness is revalidated from the locked assignment row by the + ART-04C1 caller before it prepares these facts; `TaskAssignment` has no + generation field, so 06A must not invent a parallel assignment version; +- cheap scalar lineage and digest consistency checks run before authorization + consumption without reading artifact bytes; +- service/action/lifecycle/scope denial occurs during PREP before + `PreparedArtifact.inspect()` or ZIP open; after inspection, final consumption + binds the server-computed semantic manifest and rejects replay or exact-fact + drift before workspace reservation/creation, projected checker facts, + checker dispatch, or provider access; - prepared handles never enter Celery payloads. ## Verification Commands @@ -40,5 +60,8 @@ reuse/dedup, test delta, and docs. ## Human Review Focus And Stop Conditions -Confirm this one activation precedes XINT-05A. Stop before contributor or -post-submit action activation. +Confirm this one activation follows merged ART-04B3 and AUTH-12F2 and unblocks +ART-04C1. Stop before contributor preparation/admission, `Submission`, +post-submit materialization, checker-output, review-packet, or generic-read +activation. After 06A merges, ART may execute 04C1 then 04C2 while AUTH resumes +at 12F3; neither successor is part of this chunk. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-06A-internal-review.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-06A-internal-review.md new file mode 100644 index 000000000..61ffd6ddc --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-06A-internal-review.md @@ -0,0 +1,61 @@ +# Internal Review: WS-XINT-002-06A + +## Result + +Local L1 review passes after repair. Hosted database-backed tests, full +coverage, and external review remain required on the exact PR head. + +## Blocking findings resolved + +- Architecture/product review confirmed that immutable `TaskAssignment.id` is + the assignment lineage token; replacement creates a new row/UUID and ART-04C1 + must lock the current active row before PREP. No parallel generation was + invented. +- Security, QA, and senior review found that the original request had already + been ZIP-inspected before authorization. The final design uses two-stage PREP: + service/action/lifecycle/scope and scalar facts lock before inspection, then + the same handle consumes the server-computed semantic manifest before scratch + reservation or checker execution. +- QA found that manifest/change-gate equality alone did not prove the manifest + came from the supplied inspection. The materializer now rebuilds the canonical + manifest before final consumption and denies drift without touching authority + or workspace. +- Security found missing bounded audit coordinates. Allowed decisions now carry + the exact resource-context digest plus project and prepared-generation + coordinates. +- Docs and product review found stale planned/owner wording. Canonical specs, + operations, architecture, custody, chunk maps, and status now agree. +- Test-delta and reuse review found weak mocks and swapped protocol types. Tests + now assert exact adapter arguments, pre-inspection ordering, same-handle + two-stage flow, audit coordinates, manifest drift, replay, and real PREP + behavior; the protocol matches prepare/final-consume types. + +## Final reviewer results + +- Architecture: pass with low ART-04C1 composition risk. +- Security/auth: pass with low ART-04C1 composition risk. +- Product/ops: pass with low ART-04C1 assignment-lock risk. +- QA: pass with low risk after manifest-drift repair. +- Senior engineering: pass with low risk after interface cleanup. +- CI integrity: pass with hosted database/coverage proof required. +- Reuse/dedup: pass after reusing the shared artifact PREP adapter. +- Test delta: pass after strengthened two-stage and audit tests. +- Docs: pass after runtime and custody reconciliation. + +## Local verification + +- Ruff and Ruff format on all touched backend/test files: passed. +- Focused catalogue, real PREP, adapter, two-stage materialization, denial, + manifest-drift, and default-execution tests: 28 passed, 1 deselected. +- Lightweight agent gates: 11 passed. +- Stale Workstream wording, stale AUTH docs, stale ART contracts, Markdown links, + and `git diff --check`: passed. +- Full database-backed coverage is intentionally assigned to hosted Backend CI; + the local shell has no `WORKSTREAM_TEST_DATABASE_URL`. + +## Scope confirmation + +Only `artifact.pre_submit.checker_input.materialize` changes availability. No +contributor preparation, durable admission, Submission, post-submit, +checker-output, reviewer-packet, generic-read, route, migration, or Celery +payload capability is activated. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-06A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-06A-pr-trust-bundle.md new file mode 100644 index 000000000..ad17e8812 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-06A-pr-trust-bundle.md @@ -0,0 +1,58 @@ +# PR Trust Bundle: WS-XINT-002-06A + +## Goal + +Activate exactly `artifact.pre_submit.checker_input.materialize` for +`workstream.artifact.materializer`, fail closed before private contributor +bytes reach inspection/scratch/checkers, and unblock ART-04C1 without activating +contributor submission authority. + +## Design + +- Reuse the existing opaque, process-local, single-use, transaction-bound PREP + service and fixed-service identity matrix. +- Prepare before ZIP inspection using exact task, immutable assignment UUID, + project/guide/snapshot, locked-policy, plan/catalogue, prepared-generation, + archive, and storage-scheme facts. +- Consume the same handle after inspection with the server-computed canonical + semantic-manifest hash, before scratch reservation or checker execution. +- Persist bounded authorization evidence with the full resource-context digest + and project/prepared-generation coordinates. + +## Scope + +The catalogue changes one planned action to active `WS-XINT-002-06A` custody. +No new ActionId, PermissionId, service identity, migration, alternate evaluator, +serializable handle, public route, generic artifact read, durable admission, +Submission, post-submit checker, checker output, or review capability is added. + +## Proof + +- Catalogue/service tests prove the sole availability transition and fixed + materializer identity. +- Real PREP tests prove exact preflight/final binding, transaction ownership, + replay denial, cross-resource mismatch denial, and audit digest/coordinates. +- Materialization tests prove preparation denial precedes inspection, canonical + manifest drift precedes final consumption/workspace, and final consumption + precedes scratch/checker execution. +- No test, CI gate, coverage threshold, lint rule, or workflow was weakened. +- Local focused tests passed; hosted Backend and Agent Gates remain mandatory on + the exact PR head. + +## Delivery order + +After this PR merges, ART may run 04C1 then 04C2. AUTH can concurrently resume +at 12F3. ART-04C1 owns production composition and the locked-current-assignment +revalidation immediately before PREP. + +## Reviewer result + +Architecture, security, product/ops, QA, senior engineering, CI integrity, +reuse/dedup, test-delta, and docs reviewers pass after all valid findings were +repaired. See `WS-XINT-002-06A-internal-review.md`. + +## Human review focus + +Review the two-stage ordering, exact fixed identity, complete scalar/final fact +binding, audit digest, single action activation, and the explicit ART-04C1 +composition boundary. Human approval owns merge. diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index 001235821..453e1a31e 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -28,6 +28,10 @@ ArtifactPutAttemptAuthorityFacts, ArtifactVerificationAuthorityFacts, ) +from app.modules.artifacts.submission_materialization import ( + PreSubmitMaterializationAuthorityFacts, + PreSubmitMaterializationPreparationFacts, +) from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import ( @@ -49,6 +53,8 @@ GuideSourceIngestResourceContext, GuideSourceBindingResourceContext, GuideSourceReadResourceContext, + PreSubmitCheckerInputResourceContext, + PreSubmitCheckerInputPreparationContext, HumanAuthorizationContext, PreparedAuthorizationHandleInvalid, PreparedAuthorizationUnsupported, @@ -352,8 +358,8 @@ def get_guide_artifact_prepared_authorization( return PreparedGuideArtifactAuthorization(session) -class _PreparedGuideSourceServiceAuthorization: - """Issue and consume one exact fixed-service guide capability.""" +class _PreparedArtifactServiceAuthorization: + """Issue and consume one exact fixed-service artifact capability.""" def __init__( self, @@ -372,18 +378,26 @@ def __init__( self._prepared: PreparedAuthorizationService | None = None self._input: PreparedAuthorizationInput | None = None self._handle: PreparedAuthorizationHandle | None = None - self._facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts | None = None + self._facts: ( + GuideSourceBindingAuthorityFacts + | GuideSourceReadAuthorityFacts + | PreSubmitMaterializationAuthorityFacts + | PreSubmitMaterializationPreparationFacts + | None + ) = None async def prepare( self, *, - facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts, + facts: GuideSourceBindingAuthorityFacts + | GuideSourceReadAuthorityFacts + | PreSubmitMaterializationPreparationFacts, idempotency_key: UUID, ) -> PreparedAuthorizationHandle: """Prepare one process-local capability bound to every canonical fact.""" if self._prepared is not None: - raise ArtifactAuthorityDeniedError("guide source authority is invalid") - resource = _guide_source_resource_context(facts) + raise ArtifactAuthorityDeniedError("artifact service authority is invalid") + resource = _artifact_service_resource_context(facts) try: context = await fixed_service_authorization_context( self._session, @@ -392,9 +406,7 @@ async def prepare( self._correlation_id, ) except PreparedAuthorizationUnsupported as exc: - raise ArtifactAuthorityDeniedError( - "artifact service principal is unavailable" - ) from exc + raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") from exc repository = AdminAuthorizationRepository(self._session) authorization = AuthorizationService( @@ -405,9 +417,7 @@ async def prepare( ), admin_repository=repository, ) - prepared = PreparedAuthorizationService( - self._session, context, authorization, repository - ) + prepared = PreparedAuthorizationService(self._session, context, authorization, repository) caller_input = PreparedAuthorizationInput( idempotency_key=idempotency_key, request_value=resource.model_dump(mode="json"), @@ -426,7 +436,7 @@ async def prepare( ValidationError, ) as exc: prepared.close() - raise ArtifactAuthorityDeniedError("guide source authority is unavailable") from exc + raise ArtifactAuthorityDeniedError("artifact service authority is unavailable") from exc except BaseException: prepared.close() raise @@ -440,26 +450,28 @@ async def consume( self, *, prepared_authorization: PreparedAuthorizationHandle, - facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts, + facts: GuideSourceBindingAuthorityFacts + | GuideSourceReadAuthorityFacts + | PreSubmitMaterializationAuthorityFacts, ) -> None: """Consume only the exact handle and facts prepared by this adapter.""" if ( self._prepared is None or self._input is None or self._handle is not prepared_authorization - or self._facts != facts + or not _prepared_artifact_facts_match(self._facts, facts) ): - raise ArtifactAuthorityDeniedError("guide source authority is invalid") + raise ArtifactAuthorityDeniedError("artifact service authority is invalid") prepared = self._prepared try: await prepared.consume( prepared_authorization, self._action_id, self._input, - _guide_source_resource_context(facts), + _artifact_service_resource_context(facts), ) except (AuthorizationDenied, PreparedAuthorizationHandleInvalid, ValidationError) as exc: - raise ArtifactAuthorityDeniedError("guide source authority is unavailable") from exc + raise ArtifactAuthorityDeniedError("artifact service authority is unavailable") from exc finally: prepared.close() self._prepared = None @@ -477,7 +489,7 @@ def close(self) -> None: self._facts = None -class PreparedGuideSourceBindingAuthorization(_PreparedGuideSourceServiceAuthorization): +class PreparedGuideSourceBindingAuthorization(_PreparedArtifactServiceAuthorization): """Prepared authority reserved to the fixed guide binding service.""" def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: UUID) -> None: @@ -490,7 +502,7 @@ def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: U ) -class PreparedGuideSourceReadAuthorization(_PreparedGuideSourceServiceAuthorization): +class PreparedGuideSourceReadAuthorization(_PreparedArtifactServiceAuthorization): """Prepared authority reserved to the fixed guide reader service.""" def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: UUID) -> None: @@ -503,10 +515,79 @@ def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: U ) -def _guide_source_resource_context( - facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts, -) -> GuideSourceBindingResourceContext | GuideSourceReadResourceContext: +class PreparedPreSubmitMaterializationAuthorization: + """Issue one exact capability to the fixed pre-submit materializer.""" + + def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: UUID) -> None: + """Bind the adapter to the materializer identity and action.""" + self._delegate = _PreparedArtifactServiceAuthorization( + session, + service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, + action_id=ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + request_id=request_id, + correlation_id=correlation_id, + ) + + async def prepare( + self, + *, + facts: PreSubmitMaterializationPreparationFacts, + idempotency_key: UUID, + ) -> PreparedAuthorizationHandle: + """Prepare authority from the locked scalar facts before ZIP inspection.""" + return await self._delegate.prepare(facts=facts, idempotency_key=idempotency_key) + + async def consume( + self, + *, + service_identity: ServiceIdentity, + action_id: ActionId, + prepared_authorization: PreparedAuthorizationHandle, + 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, + ) + + def close(self) -> None: + """Discard any process-local prepared capability still held by the adapter.""" + self._delegate.close() + + +def _artifact_service_resource_context( + facts: GuideSourceBindingAuthorityFacts + | GuideSourceReadAuthorityFacts + | PreSubmitMaterializationAuthorityFacts + | PreSubmitMaterializationPreparationFacts, +) -> ( + GuideSourceBindingResourceContext + | GuideSourceReadResourceContext + | PreSubmitCheckerInputResourceContext + | PreSubmitCheckerInputPreparationContext +): + """Compose the canonical AUTH resource context for fixed ART service facts.""" values = asdict(facts) + if isinstance(facts, PreSubmitMaterializationAuthorityFacts): + values = asdict(facts.preparation) + return PreSubmitCheckerInputResourceContext( + resource_type="pre_submit_checker_input", + resource_id=facts.preparation.prepared_generation_id, + semantic_manifest_sha256=facts.semantic_manifest_sha256, + **values, + ) + if isinstance(facts, PreSubmitMaterializationPreparationFacts): + return PreSubmitCheckerInputPreparationContext( + resource_type="pre_submit_checker_input", + resource_id=facts.prepared_generation_id, + **values, + ) if isinstance(facts, GuideSourceBindingAuthorityFacts): return GuideSourceBindingResourceContext( resource_type="guide_source_binding", @@ -520,6 +601,24 @@ def _guide_source_resource_context( ) +def _prepared_artifact_facts_match( + prepared: GuideSourceBindingAuthorityFacts + | GuideSourceReadAuthorityFacts + | PreSubmitMaterializationPreparationFacts + | PreSubmitMaterializationAuthorityFacts, + final: GuideSourceBindingAuthorityFacts + | GuideSourceReadAuthorityFacts + | PreSubmitMaterializationAuthorityFacts, +) -> bool: + """Require final facts to preserve every fact bound during preparation.""" + if isinstance(prepared, PreSubmitMaterializationPreparationFacts): + return ( + isinstance(final, PreSubmitMaterializationAuthorityFacts) + and prepared == final.preparation + ) + return prepared == final + + class PreparedArtifactInternalAuthority: """Adapt one fixed ART service to the shared transaction-bound PREP kernel.""" @@ -668,9 +767,7 @@ async def _service_context(self) -> ServiceAuthorizationContext: self._correlation_id, ) except PreparedAuthorizationUnsupported as exc: - raise ArtifactAuthorityDeniedError( - "artifact service principal is unavailable" - ) from exc + raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") from exc def _scope( diff --git a/backend/app/modules/artifacts/submission_materialization.py b/backend/app/modules/artifacts/submission_materialization.py index 60ab6334f..af1e88c4d 100644 --- a/backend/app/modules/artifacts/submission_materialization.py +++ b/backend/app/modules/artifacts/submission_materialization.py @@ -2,26 +2,30 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Protocol, final from uuid import UUID from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from app.core.hashing import canonical_json_hash from app.interfaces.artifact_operations import PreparedBundleMaterializationRequest from app.modules.actors.service_identities import ServiceIdentity 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 ( 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 ( ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, DefaultPreSubmissionExecutionInput, @@ -31,28 +35,72 @@ ) -@final @dataclass(frozen=True, slots=True) -class PreSubmitMaterializationAuthorityFacts: - """Exact process-local resource facts bound to fixed materializer authority.""" +class PreSubmitMaterializationPreparationFacts: + """Exact scalar facts available before any ZIP inspection.""" task_id: UUID assignment_id: UUID project_id: UUID + guide_id: UUID + guide_version: int + source_snapshot_id: UUID + source_snapshot_hash: str submission_artifact_policy_id: UUID + submission_artifact_policy_hash: str checker_policy_id: UUID + checker_policy_hash: str prepared_generation_id: UUID plan_sha256: str catalogue_manifest_sha256: str archive_sha256: str archive_byte_count: int - semantic_manifest_sha256: str storage_scheme: str +@final +@dataclass(frozen=True, slots=True) +class PreSubmitMaterializationAuthorityFacts(PreSubmitMaterializationPreparationFacts): + """Exact inspected resource facts consumed before scratch exposure.""" + + semantic_manifest_sha256: str + + @property + def preparation(self) -> PreSubmitMaterializationPreparationFacts: + """Return the exact pre-inspection projection of the final facts.""" + return PreSubmitMaterializationPreparationFacts( + task_id=self.task_id, + assignment_id=self.assignment_id, + project_id=self.project_id, + guide_id=self.guide_id, + guide_version=self.guide_version, + source_snapshot_id=self.source_snapshot_id, + source_snapshot_hash=self.source_snapshot_hash, + submission_artifact_policy_id=self.submission_artifact_policy_id, + submission_artifact_policy_hash=self.submission_artifact_policy_hash, + checker_policy_id=self.checker_policy_id, + checker_policy_hash=self.checker_policy_hash, + prepared_generation_id=self.prepared_generation_id, + plan_sha256=self.plan_sha256, + catalogue_manifest_sha256=self.catalogue_manifest_sha256, + archive_sha256=self.archive_sha256, + archive_byte_count=self.archive_byte_count, + storage_scheme=self.storage_scheme, + ) + + class PreSubmitMaterializationAuthorization(Protocol): """Adapter over AUTH's opaque transaction-bound prepared capability.""" + async def prepare( + self, + *, + facts: PreSubmitMaterializationPreparationFacts, + idempotency_key: UUID, + ) -> PreparedAuthorizationHandle: + """Prepare an opaque capability before any submitted byte is inspected.""" + ... + async def consume( self, *, @@ -60,12 +108,26 @@ async def consume( action_id: ActionId, prepared_authorization: PreparedAuthorizationHandle, facts: PreSubmitMaterializationAuthorityFacts, - ) -> None: ... + ) -> None: + """Consume the capability against the server-computed final facts.""" + ... class DenyPreSubmitMaterializationAuthorization: """Keep production byte access unavailable until XINT-06A activation.""" + async def prepare( + self, + *, + facts: PreSubmitMaterializationPreparationFacts, + idempotency_key: UUID, + ) -> PreparedAuthorizationHandle: + """Deny capability preparation while the production adapter is absent.""" + del facts, idempotency_key + raise ArtifactAuthorityDeniedError( + "pre-submit checker input materialization is unavailable" + ) + async def consume( self, *, @@ -74,6 +136,7 @@ async def consume( prepared_authorization: PreparedAuthorizationHandle, facts: PreSubmitMaterializationAuthorityFacts, ) -> None: + """Deny capability consumption while the production adapter is absent.""" del service_identity, action_id, prepared_authorization, facts raise ArtifactAuthorityDeniedError( "pre-submit checker input materialization is unavailable" @@ -92,6 +155,7 @@ def __init__( catalogue: PreSubmissionCheckerCatalogue, 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 @@ -135,31 +199,100 @@ async def materialize_prepared_bundle( maximum_entries=request.manifest.entry_count, ) + async def prepare_authorization( + self, + *, + task_id: UUID, + assignment_id: UUID, + submission_artifact_policy_id: UUID, + checker_policy_id: UUID, + prepared_artifact: PreparedArtifact, + effective_plan: EffectivePreSubmissionExecutionPlan, + idempotency_key: UUID, + ) -> PreparedAuthorizationHandle: + """Deny unavailable service authority before inspecting the ZIP.""" + facts = self._preparation_facts( + task_id=task_id, + assignment_id=assignment_id, + submission_artifact_policy_id=submission_artifact_policy_id, + checker_policy_id=checker_policy_id, + prepared_artifact=prepared_artifact, + effective_plan=effective_plan, + ) + return await self._authorization.prepare( + facts=facts, + idempotency_key=idempotency_key, + ) + def _authority_facts( self, request: PreparedBundleMaterializationRequest, ) -> PreSubmitMaterializationAuthorityFacts: + """Build final authority facts from the canonical inspected manifest.""" plan = request.effective_plan + preparation = self._preparation_facts( + task_id=request.task_id, + assignment_id=request.assignment_id, + submission_artifact_policy_id=request.submission_artifact_policy_id, + checker_policy_id=request.checker_policy_id, + prepared_artifact=request.prepared_artifact, + effective_plan=plan, + ) if ( - request.submission_artifact_policy_id != plan.lineage.effective_policy_id - or request.checker_policy_id != plan.lineage.pre_submit_policy_id + request.manifest != request.change_gate.manifest + or build_submission_manifest(request.inspection) != request.manifest + or request.prepared_artifact.commitment.sha256 != request.change_gate.archive_sha256 + or request.prepared_artifact.commitment.byte_count + != request.change_gate.archive_byte_count ): raise PreSubmissionInfrastructureUnavailable( "pre_submission_materialization_context_invalid" ) - commitment = request.prepared_artifact.commitment return PreSubmitMaterializationAuthorityFacts( - task_id=request.task_id, - assignment_id=request.assignment_id, + **asdict(preparation), + semantic_manifest_sha256=request.manifest.sha256, + ) + + def _preparation_facts( + self, + *, + task_id: UUID, + assignment_id: UUID, + submission_artifact_policy_id: UUID, + checker_policy_id: UUID, + prepared_artifact: PreparedArtifact, + effective_plan: EffectivePreSubmissionExecutionPlan, + ) -> PreSubmitMaterializationPreparationFacts: + """Build pre-inspection facts from locked lineage and byte commitment.""" + plan = effective_plan + if plan.plan_sha256 != canonical_json_hash(plan.as_dict()): + raise PreSubmissionInfrastructureUnavailable("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 + ): + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_materialization_context_invalid" + ) + commitment = prepared_artifact.commitment + return PreSubmitMaterializationPreparationFacts( + task_id=task_id, + assignment_id=assignment_id, project_id=plan.lineage.project_id, - submission_artifact_policy_id=request.submission_artifact_policy_id, - checker_policy_id=request.checker_policy_id, - prepared_generation_id=request.prepared_artifact.generation_id, + guide_id=plan.lineage.guide_id, + guide_version=plan.lineage.guide_version, + source_snapshot_id=plan.lineage.source_snapshot_id, + source_snapshot_hash=plan.lineage.source_snapshot_hash, + submission_artifact_policy_id=submission_artifact_policy_id, + submission_artifact_policy_hash=plan.lineage.effective_policy_hash, + checker_policy_id=checker_policy_id, + checker_policy_hash=plan.lineage.pre_submit_policy_bundle_hash, + prepared_generation_id=prepared_artifact.generation_id, plan_sha256=plan.plan_sha256, catalogue_manifest_sha256=plan.catalogue_manifest_sha256, archive_sha256=commitment.sha256, archive_byte_count=commitment.byte_count, - semantic_manifest_sha256=request.manifest.sha256, storage_scheme=self._storage_scheme, ) @@ -173,6 +306,7 @@ def __init__( session: AsyncSession, materialization: PreparedBundleMaterializationService, ) -> None: + """Bind execution to the transaction used for durable evidence.""" self._session = session self._materialization = materialization @@ -193,9 +327,7 @@ async def execute( prepared_generation_id = request.prepared_artifact.generation_id execution = await self._materialization.materialize_prepared_bundle(request) async with self._session.begin(): - await self._session.execute( - text("set transaction isolation level read committed") - ) + await self._session.execute(text("set transaction isolation level read committed")) return await PreSubmitEvidenceService(self._session).persist( PreSubmitEvidencePersistenceRequest( actor_profile_id=actor_profile_id, diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index f00050619..e4db2bb0f 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -35,7 +35,8 @@ _RESOURCE_TYPES = frozenset( """actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task submission review contribution compensation_award compensation_delivery operations - audit_event project_create_operation project_submission_artifact_policy_mutation""".split() + audit_event project_create_operation project_submission_artifact_policy_mutation + pre_submit_checker_input""".split() ) _UUID_TARGET_KINDS = frozenset( { diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 7ccc50793..d045a6e76 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -222,6 +222,7 @@ class ActionOwner(StrEnum): AUTH_12E = "WS-AUTH-001-12E" AUTH_12F = "WS-AUTH-001-12F" AUTH_12F2 = "WS-AUTH-001-12F2" + XINT_002_06A = "WS-XINT-002-06A" AUTH_12G = "WS-AUTH-001-12G" AUTH_12H = "WS-AUTH-001-12H" AUTH_13 = "WS-AUTH-001-13" @@ -235,7 +236,6 @@ class ActionOwner(StrEnum): AUTH_REV_12 = "WS-AUTH-001-REV-12" AUTH_ART_02D_INTERNAL = "WS-AUTH-001-ART-02D-INTERNAL" AUTH_ART_02D_OPERATOR = "WS-AUTH-001-ART-02D-OPERATOR" - AUTH_ART_04B = "WS-AUTH-001-ART-04B" AUTH_ART_05 = "WS-AUTH-001-ART-05" AUTH_ART_06A = "WS-AUTH-001-ART-06A" AUTH_ART_06B = "WS-AUTH-001-ART-06B" @@ -723,10 +723,10 @@ def _active( PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionOwner.AUTH_ART_02D_INTERNAL, ), - _planned( + _active( ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.AUTH_ART_04B, + ActionOwner.XINT_002_06A, ), _planned( ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, @@ -857,6 +857,7 @@ def _index_actions( ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, } if { definition.action_id @@ -1000,7 +1001,7 @@ def _index_service_actions( ), ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.AUTH_ART_04B, + ActionOwner.XINT_002_06A, ), ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, @@ -1074,6 +1075,7 @@ def _index_service_actions( ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, } else ActionAvailability.PLANNED diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 565f5fe7e..ad618c3ea 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -48,6 +48,7 @@ ArtifactVerificationJobResourceContext, GuideSourceBindingResourceContext, GuideSourceReadResourceContext, + PreSubmitCheckerInputResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -189,6 +190,10 @@ "guide_source_read", GuideSourceReadResourceContext, ), + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + "pre_submit_checker_input", + PreSubmitCheckerInputResourceContext, + ), ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( "artifact_put_attempt", ArtifactPutAttemptResourceContext, @@ -980,9 +985,7 @@ async def _require_prelocked( denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if action_id is ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: if denial is None and ( - not isinstance( - resource_context, ProjectGuideSufficiencyMutationResourceContext - ) + not isinstance(resource_context, ProjectGuideSufficiencyMutationResourceContext) or resource_context.execution_kind != "setup_service" or resource_context.scope_project_id != authority.scope_project_id ): @@ -1521,6 +1524,12 @@ async def _stage_decision( audit_resource_id = str(resource_context.resource_id) target_ref_kind = "project" target_ref_id = str(resource_context.scope_project_id) + elif isinstance(resource_context, PreSubmitCheckerInputResourceContext): + audit_project_id = str(resource_context.project_id) + audit_resource_type = resource_context.resource_type + audit_resource_id = str(resource_context.resource_id) + target_ref_kind = "project" + target_ref_id = str(resource_context.project_id) elif decision.action_id in _GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS: if resource_context is not None: project_id = self._resource_project_id(resource_context) @@ -1537,6 +1546,7 @@ async def _stage_decision( "artifact_pending_work", "guide_source_binding", "guide_source_read", + "pre_submit_checker_input", "project_diagnostic", "project_policy_read", "project_active_guide_read", diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index c8bd3f918..218123772 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -32,6 +32,8 @@ GuideSourceBindingResourceContext, GuideSourceReadResourceContext, GuideSourceIngestResourceContext, + PreSubmitCheckerInputResourceContext, + PreSubmitCheckerInputPreparationContext, AuthorizationContext, AuthorizationDenialCode, AuthorizationDecision, @@ -93,7 +95,7 @@ def __reduce__(self) -> NoReturn: _HANDLE_CONSTRUCTOR_TOKEN = object() -_GUIDE_RESOURCE_BY_ACTION = { +_EXACT_ARTIFACT_RESOURCE_BY_ACTION = { ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE: ( "guide_source_binding", GuideSourceBindingResourceContext, @@ -102,6 +104,10 @@ def __reduce__(self) -> NoReturn: "guide_source_read", GuideSourceReadResourceContext, ), + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + "pre_submit_checker_input", + PreSubmitCheckerInputResourceContext, + ), } @@ -147,6 +153,8 @@ class _PreparedAuthorizationBinding: sufficiency_setup_service_custody: dict | None = None submission_policy_context: dict | None = None submission_policy_resource_digest: str | None = None + exact_artifact_context: dict | None = None + exact_artifact_resource_digest: str | None = None @dataclass(slots=True) @@ -265,6 +273,21 @@ def _submission_policy_binding_matches( ) and binding.submission_policy_resource_digest == authorization_resource_digest(resource) +def _exact_artifact_binding_matches( + binding: _PreparedAuthorizationBinding, + resource: PreSubmitCheckerInputResourceContext, +) -> bool: + """Require every materialization fact to equal the prepared request.""" + preparation = PreSubmitCheckerInputPreparationContext.model_validate( + resource.model_dump(mode="python", exclude={"semantic_manifest_sha256"}) + ) + context = preparation.model_dump(mode="json") + return binding.exact_artifact_context == context and ( + binding.exact_artifact_resource_digest + == canonical_json_hash({"pre_submit_checker_input_preparation": context}) + ) + + _CONSUMED = _Consumed() @@ -363,6 +386,10 @@ async def consume( final_resource_context, ProjectSubmissionArtifactPolicyMutationResourceContext ) and not _submission_policy_binding_matches(issuance.binding, final_resource_context): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if isinstance( + final_resource_context, PreSubmitCheckerInputResourceContext + ) and not _exact_artifact_binding_matches(issuance.binding, final_resource_context): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") self._issued[handle] = _CONSUMED return await self._authorization._require_prelocked( self._consumer_token, @@ -456,6 +483,32 @@ def _binding( sufficiency: dict[str, object] = {} submission_policy_context: dict | None = None submission_policy_resource_digest: str | None = None + exact_artifact_context: dict | None = None + exact_artifact_resource_digest: str | None = None + if action_id is ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: + try: + value = dict(caller_input.request_value) + for field in ( + "resource_id", + "task_id", + "assignment_id", + "project_id", + "guide_id", + "source_snapshot_id", + "submission_artifact_policy_id", + "checker_policy_id", + "prepared_generation_id", + ): + value[field] = UUID(str(value[field])) + resource = PreSubmitCheckerInputPreparationContext.model_validate(value) + except (KeyError, TypeError, ValueError) as exc: + raise PreparedAuthorizationHandleInvalid( + "invalid prepared authorization handle" + ) from exc + exact_artifact_context = resource.model_dump(mode="json") + exact_artifact_resource_digest = canonical_json_hash( + {"pre_submit_checker_input_preparation": exact_artifact_context} + ) if action_id is ActionId.PROJECT_CREATE: try: operation_id = UUID(str(caller_input.request_value["operation_id"])) @@ -692,6 +745,8 @@ def _binding( ), submission_policy_context=submission_policy_context, submission_policy_resource_digest=submission_policy_resource_digest, + exact_artifact_context=exact_artifact_context, + exact_artifact_resource_digest=exact_artifact_resource_digest, ) @staticmethod @@ -699,11 +754,11 @@ def _scope_from_resource( action_id: ActionId, resource: AuthorizationResourceContext, ) -> PreparedAuthorityScope: - guide_resource = _GUIDE_RESOURCE_BY_ACTION.get(action_id) - if guide_resource is not None and isinstance(resource, guide_resource[1]): + artifact_resource = _EXACT_ARTIFACT_RESOURCE_BY_ACTION.get(action_id) + if artifact_resource is not None and isinstance(resource, artifact_resource[1]): return PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, - artifact_resource_type=guide_resource[0], + artifact_resource_type=artifact_resource[0], artifact_resource_id=resource.resource_id, ) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF and isinstance( diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 58264ca33..399c887a7 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -112,6 +112,7 @@ class PreparedAuthorityScope(BaseModel): "artifact_pending_work", "guide_source_binding", "guide_source_read", + "pre_submit_checker_input", ] | None ) = None @@ -171,6 +172,7 @@ def validate_selector(self): "artifact_verification_job", "guide_source_binding", "guide_source_read", + "pre_submit_checker_input", } and isinstance(self.artifact_resource_id, UUID) ) @@ -1447,6 +1449,44 @@ def bind_artifact_binding(self): return self +class PreSubmitCheckerInputPreparationContext(BaseModel): + """Pre-inspection facts used to lock materializer authority.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["pre_submit_checker_input"] + resource_id: UUID + task_id: UUID + assignment_id: UUID + project_id: UUID + guide_id: UUID + guide_version: int = Field(gt=0) + source_snapshot_id: UUID + source_snapshot_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + submission_artifact_policy_id: UUID + submission_artifact_policy_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + checker_policy_id: UUID + checker_policy_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + prepared_generation_id: UUID + plan_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + catalogue_manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + archive_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + archive_byte_count: int = Field(ge=0) + storage_scheme: Literal["local", "s3"] + + @model_validator(mode="after") + def bind_prepared_generation(self): + """Use the process-local prepared generation as the opaque selector.""" + if self.resource_id != self.prepared_generation_id: + raise ValueError("pre-submit checker input resource must match generation") + return self + + +class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationContext): + """Exact inspected contributor bundle authorized for checker materialization.""" + + semantic_manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + AuthorizationResourceContext = ( ActorSelfResourceContext | ProjectReadResourceContext @@ -1488,6 +1528,7 @@ def bind_artifact_binding(self): | ArtifactPendingWorkResourceContext | GuideSourceBindingResourceContext | GuideSourceReadResourceContext + | PreSubmitCheckerInputResourceContext ) @@ -1576,6 +1617,7 @@ class AuthorizationDecision(BaseModel): "artifact_pending_work", "guide_source_binding", "guide_source_read", + "pre_submit_checker_input", ] resource_id: ( UUID diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 741b89908..ffd842673 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -214,6 +214,7 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, } artifact_allowed = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index a1ca51d6e..e400c7cd9 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -9,7 +9,7 @@ import copy from collections import UserDict from collections.abc import Iterator, Mapping -from dataclasses import replace +from dataclasses import asdict, replace from datetime import UTC, datetime import inspect import hashlib @@ -67,6 +67,10 @@ from app.modules.artifacts.authorization import ( PreparedGuideSourceBindingAuthorization, PreparedGuideSourceReadAuthorization, + PreparedPreSubmitMaterializationAuthorization, +) +from app.modules.artifacts.submission_materialization import ( + PreSubmitMaterializationAuthorityFacts, ) from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, @@ -192,6 +196,8 @@ ArtifactVerificationJobResourceContext, GuideSourceBindingResourceContext, GuideSourceReadResourceContext, + PreSubmitCheckerInputPreparationContext, + PreSubmitCheckerInputResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -1744,8 +1750,8 @@ async def consume_rate() -> None: ), "artifact.pre_submit.checker_input.materialize": ( "artifact.checker_input.materialize", - "WS-AUTH-001-ART-04B", - "planned", + "WS-XINT-002-06A", + "active", ), "artifact.submission.binding.create": ( "artifact.binding.create", @@ -2148,6 +2154,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, } assert { definition.action_id.value: ( @@ -2178,7 +2185,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionOwner.AUTH_ART_02D_OPERATOR, ActionOwner.AUTH_ART_02D_INTERNAL, ActionOwner.XINT_002_04B, - ActionOwner.AUTH_ART_04B, + ActionOwner.XINT_002_06A, ActionOwner.AUTH_ART_05, ActionOwner.AUTH_ART_06A, ActionOwner.AUTH_ART_06B, @@ -2190,7 +2197,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionOwner.AUTH_ART_02D_OPERATOR: 8, ActionOwner.AUTH_ART_02D_INTERNAL: 3, ActionOwner.XINT_002_04B: 2, - ActionOwner.AUTH_ART_04B: 1, + ActionOwner.XINT_002_06A: 1, ActionOwner.AUTH_ART_05: 1, ActionOwner.AUTH_ART_06A: 1, ActionOwner.AUTH_ART_06B: 2, @@ -2229,14 +2236,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 50 + == 51 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 50 + == 49 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2398,9 +2405,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: action_id: ProjectSubmissionArtifactPolicyMutationResourceContext( resource_type="project_submission_artifact_policy_mutation", resource_id=( - submission_policy_successor_id - if target_kind == "update" - else submission_policy_id + submission_policy_successor_id if target_kind == "update" else submission_policy_id ), operation_id=operation_id, request_digest=DIGEST, @@ -2798,6 +2803,7 @@ def test_submission_artifact_policy_create_update_activation_is_12f2_only() -> N active_internal = { ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, @@ -4493,7 +4499,6 @@ async def test_project_diagnostic_read_requires_exact_active_admin_grant_and_chi assert evidence.events[0].after_facts["resource_context_digest"] == ( decision.resource_context_digest ) - service, _ = _runtime_service( context, admin_repository=_ProjectReadAuthorityFacts(admin_grant=grant), @@ -5766,6 +5771,117 @@ async def revalidate(current: ServiceAuthorizationContext, _action: ActionId): await prepared.consume(handle, action_id, caller_input, resource) +@pytest.mark.asyncio +async def test_real_prepared_materializer_binds_preflight_and_final_evidence() -> None: + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, + ) + assert isinstance(context, ServiceAuthorizationContext) + session = _PreparedTestSession() + + class LockedServiceFacts: + async def lock_request_actor(self, identity_link_id, actor_profile_id): + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status="active", + ), + SimpleNamespace( + id=str(actor_profile_id), + actor_kind="service", + status="active", + service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER.value, + ), + ) + + repository = LockedServiceFacts() + authorization, evidence = _runtime_service( + context, + session=session, + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + repository, + ) + generation_id = uuid4() + common = { + "resource_type": "pre_submit_checker_input", + "resource_id": generation_id, + "task_id": uuid4(), + "assignment_id": uuid4(), + "project_id": uuid4(), + "guide_id": uuid4(), + "guide_version": 1, + "source_snapshot_id": uuid4(), + "source_snapshot_hash": "sha256:" + "1" * 64, + "submission_artifact_policy_id": uuid4(), + "submission_artifact_policy_hash": "sha256:" + "2" * 64, + "checker_policy_id": uuid4(), + "checker_policy_hash": "sha256:" + "3" * 64, + "prepared_generation_id": generation_id, + "plan_sha256": "sha256:" + "4" * 64, + "catalogue_manifest_sha256": "sha256:" + "5" * 64, + "archive_sha256": "sha256:" + "6" * 64, + "archive_byte_count": 10, + "storage_scheme": "s3", + } + preflight = PreSubmitCheckerInputPreparationContext(**common) + final = PreSubmitCheckerInputResourceContext( + **common, + semantic_manifest_sha256="sha256:" + "7" * 64, + ) + caller = PreparedAuthorizationInput( + idempotency_key=uuid4(), + request_value=preflight.model_dump(mode="json"), + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type="pre_submit_checker_input", + artifact_resource_id=generation_id, + ) + handle = await prepared.prepare( + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + caller, + scope, + ) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + caller, + final.model_copy(update={"assignment_id": uuid4()}), + ) + decision = await prepared.consume( + handle, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + caller, + final, + ) + assert decision.allowed is True + assert decision.matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE + assert decision.resource_context_digest == authorization_resource_digest(final) + assert evidence.events[0].after_facts["resource_context_digest"] == ( + decision.resource_context_digest + ) + assert evidence.events[0].resource_type == "pre_submit_checker_input" + assert evidence.events[0].resource_id == str(generation_id) + assert evidence.events[0].project_id == str(final.project_id) + assert evidence.events[0].target_ref_kind == "project" + assert evidence.events[0].target_ref_id == str(final.project_id) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + caller, + final, + ) + + @pytest.mark.asyncio async def test_prepared_sufficiency_run_admits_only_exact_setup_service_custody() -> None: context = _runtime_context( @@ -6033,6 +6149,130 @@ def close(self) -> None: await authority.consume(prepared_authorization=handle, facts=facts) +@pytest.mark.asyncio +async def test_pre_submit_materializer_adapter_binds_every_fact_and_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = _PreparedTestSession() + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + service_identity=ServiceIdentity.ARTIFACT_MATERIALIZER, + ) + assert isinstance(context, ServiceAuthorizationContext) + + async def fixed_context(*_args): + return context + + prepared_instances = [] + + class FakePrepared: + def __init__(self, *_args) -> None: + self.handle = object.__new__(PreparedAuthorizationHandle) + self.prepared_args = None + self.consumed_args = None + prepared_instances.append(self) + + async def prepare(self, *args): + self.prepared_args = args + return self.handle + + async def consume(self, handle, *args): + assert handle is self.handle + self.consumed_args = args + + def close(self) -> None: + return None + + monkeypatch.setattr( + artifact_authorization, + "fixed_service_authorization_context", + fixed_context, + ) + monkeypatch.setattr(artifact_authorization, "PreparedAuthorizationService", FakePrepared) + facts = PreSubmitMaterializationAuthorityFacts( + task_id=uuid4(), + assignment_id=uuid4(), + project_id=uuid4(), + guide_id=uuid4(), + guide_version=1, + source_snapshot_id=uuid4(), + source_snapshot_hash="sha256:" + "1" * 64, + submission_artifact_policy_id=uuid4(), + submission_artifact_policy_hash="sha256:" + "2" * 64, + checker_policy_id=uuid4(), + checker_policy_hash="sha256:" + "3" * 64, + prepared_generation_id=uuid4(), + plan_sha256="sha256:" + "4" * 64, + catalogue_manifest_sha256="sha256:" + "5" * 64, + archive_sha256="sha256:" + "6" * 64, + archive_byte_count=10, + semantic_manifest_sha256="sha256:" + "7" * 64, + storage_scheme="s3", + ) + authority = PreparedPreSubmitMaterializationAuthorization( + session, # type: ignore[arg-type] + request_id=uuid4(), + correlation_id=uuid4(), + ) + handle = await authority.prepare(facts=facts.preparation, idempotency_key=uuid4()) + prepared_instance = prepared_instances[0] + prepared_action, caller_input, scope = prepared_instance.prepared_args + assert prepared_action is ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE + assert caller_input.request_value == PreSubmitCheckerInputPreparationContext( + resource_type="pre_submit_checker_input", + resource_id=facts.prepared_generation_id, + **asdict(facts.preparation), + ).model_dump(mode="json") + assert scope == PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type="pre_submit_checker_input", + artifact_resource_id=facts.prepared_generation_id, + ) + for field_name in facts.preparation.__dataclass_fields__: + original = getattr(facts, field_name) + changed = ( + uuid4() + if isinstance(original, UUID) + else (original + 1 if isinstance(original, int) else f"changed-{original}") + ) + 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, + ) + consumed_action, consumed_input, consumed_resource = prepared_instance.consumed_args + assert consumed_action is ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE + assert consumed_input is caller_input + assert consumed_resource == PreSubmitCheckerInputResourceContext( + resource_type="pre_submit_checker_input", + resource_id=facts.prepared_generation_id, + **asdict(facts.preparation), + semantic_manifest_sha256=facts.semantic_manifest_sha256, + ) + 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, + ) + + @pytest.mark.asyncio async def test_fixed_service_context_rejects_mismatched_loaded_identity( monkeypatch: pytest.MonkeyPatch, diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index fe1c9b43b..36808cd87 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -167,10 +167,16 @@ def _limits() -> ArtifactPreparationLimits: class _AllowAuthority: def __init__(self) -> None: + self.preparation_facts = None self.facts = None self.action_id = None self.service_identity = None + async def prepare(self, *, facts, idempotency_key): + del idempotency_key + self.preparation_facts = facts + return _handle() + async def consume(self, **values): self.facts = values["facts"] self.action_id = values["action_id"] @@ -248,6 +254,126 @@ async def test_authority_denial_precedes_workspace_and_checker_access(tmp_path: manager.close() +@pytest.mark.asyncio +async def test_authority_preparation_denies_before_zip_inspection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + catalogue = build_pre_submission_checker_catalogue() + plan = _plan(catalogue) + manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) + preparation = ArtifactPreparationService(manager) + prepared = await preparation.prepare(_bytes(_archive()), media_type="application/zip") + inspection_calls = 0 + + async def forbidden_inspection(*_args, **_kwargs): + nonlocal inspection_calls + inspection_calls += 1 + raise AssertionError("ZIP inspection preceded authority preparation") + + monkeypatch.setattr(type(prepared), "inspect", forbidden_inspection) + service = PreparedBundleMaterializationService( + authorization=DenyPreSubmitMaterializationAuthorization(), + preparation=preparation, + archive_inspector=SubmissionArchiveInspector(SubmissionArchiveLimits()), + catalogue=catalogue, + storage_scheme="s3", + ) + + with pytest.raises(ArtifactAuthorityDeniedError): + await service.prepare_authorization( + task_id=uuid4(), + assignment_id=uuid4(), + submission_artifact_policy_id=plan.lineage.effective_policy_id, + checker_policy_id=plan.lineage.pre_submit_policy_id, + prepared_artifact=prepared, + effective_plan=plan, + idempotency_key=uuid4(), + ) + + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + assert inspection_calls == 0 + await prepared.close() + manager.close() + + +@pytest.mark.asyncio +async def test_manifest_drift_denies_before_authority_and_workspace(tmp_path: Path) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + first = next(entry for entry in request.inspection.entries if entry.sha256 is not None) + forged_entry = replace(first, sha256="sha256:" + "f" * 64) + forged_inspection = replace( + request.inspection, + entries=tuple( + forged_entry if entry is first else entry for entry in request.inspection.entries + ), + ) + forged_manifest = build_submission_manifest(forged_inspection) + forged_change = evaluate_submission_change( + commitment=request.prepared_artifact.commitment, + manifest=forged_manifest, + predecessor=None, + predecessor_exists=False, + ) + authority = _AllowAuthority() + service = PreparedBundleMaterializationService( + authorization=authority, + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + storage_scheme="s3", + ) + + with pytest.raises( + PreSubmissionInfrastructureUnavailable, + match="materialization_context_invalid", + ): + await service.materialize_prepared_bundle( + replace(request, manifest=forged_manifest, change_gate=forged_change) + ) + + assert authority.facts is None + assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_two_stage_authority_uses_one_handle_and_exact_final_facts( + tmp_path: Path, +) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + authority = _AllowAuthority() + service = PreparedBundleMaterializationService( + authorization=authority, + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + storage_scheme="s3", + ) + handle = await service.prepare_authorization( + task_id=request.task_id, + assignment_id=request.assignment_id, + submission_artifact_policy_id=request.submission_artifact_policy_id, + checker_policy_id=request.checker_policy_id, + prepared_artifact=request.prepared_artifact, + effective_plan=request.effective_plan, + idempotency_key=uuid4(), + ) + + result = await service.materialize_prepared_bundle( + replace(request, prepared_authorization=handle) + ) + + assert result.eligible is True + assert authority.preparation_facts is not None + assert authority.facts is not None + assert authority.facts.preparation == authority.preparation_facts + assert authority.facts.semantic_manifest_sha256 == request.manifest.sha256 + await request.prepared_artifact.close() + manager.close() + + @pytest.mark.asyncio async def test_effective_evidence_workflow_persists_once_and_replays_exactly( tmp_path: Path, diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index b72c182e3..7eaf0667f 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -257,8 +257,10 @@ facts, projects one callback-scoped sealed tree through ART scratch custody, and returns bounded entry results: `passed`, `warning`, `failed`, `advisory_disabled`, or `dependency_not_run`. It does not consult the legacy checker registry or standalone precheck, does not run `project_policy` -primitives, and does not persist checker evidence. Fixed materializer authority -remains planned/unavailable until XINT-06A. +primitives, and does not persist checker evidence. XINT-06A activates only the +fixed pre-submit materializer through two-stage PREP: scalar service/resource +facts are locked before ZIP inspection, and the same handle consumes the final +server-computed semantic-manifest fact before scratch or checker execution. Project policy adds required artifacts, evidence requirements, stricter forbidden artifacts, stricter packaging rules, and project-specific attestation requirements. diff --git a/docs/architecture_lockdown.md b/docs/architecture_lockdown.md index 181575815..eef75f54c 100644 --- a/docs/architecture_lockdown.md +++ b/docs/architecture_lockdown.md @@ -136,11 +136,14 @@ is audit evidence, not a product review decision. The independently invocable legacy preflight route remains frozen until ART-05B removes it together with the legacy Submission path; it is never an alternate authority for this flow. -Hidden ART-04B2 authorizes the fixed materializer before any prepared-byte read, -reserves the complete expanded workspace budget, verifies one canonical sealed -tree against the 04A identities, and runs only platform/default catalogue -entries. The callback-scoped tree and all paths are destroyed before bounded -results return. Hidden ART-04B3 extends that same plan and sealed-tree callback +Hidden ART-04B2 uses XINT-06A's two-stage fixed-materializer PREP. It locks the +service/action and scalar lineage before ZIP inspection, then consumes the same +process-local handle with the server-computed semantic-manifest identity before +workspace reservation or checker execution. It reserves the complete expanded +workspace budget, verifies one canonical sealed tree against the 04A identities, +and runs only platform/default catalogue entries. The callback-scoped tree and +all paths are destroyed before bounded results return. Hidden ART-04B3 extends +that same plan and sealed-tree callback to the locked project-policy entries, then reloads the exact task context and persists one immutable platform-plus-project evidence set after scratch cleanup. It creates no provider object, admission, Submission, or lifecycle effect; diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 571b00f21..d6057b623 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -807,16 +807,18 @@ compositions are the deliberate exception: their adapter retains the exact denial, the composition root first rolls back ART state, and AUTH's public bounded restage operation commits the same denial in a clean AUTH-only transaction. The two guide binding/read adapters do not use that exception. -Still-planned fixed-service preparation still issues no handle. When a planned -pre-submit materializer request is diagnosed, expect denial before any ZIP read -or scratch workspace reservation and no ART checker result. The future XINT-06A -activation must bind task, assignment, project, effective-policy ID, -pre-submit-policy ID, process-local prepared generation, effective-plan and -catalogue hashes, archive digest/size, and semantic-manifest hash to +Still-planned fixed-service preparation still issues no handle. XINT-06A +activates the pre-submit materializer only for +`workstream.artifact.materializer`. PREP first locks the service/action and the +scalar task, assignment, project, policy, plan, catalogue, archive, generation, +and storage-scheme facts before ZIP inspection. After inspection, the same +process-local handle is consumed with the server-computed semantic-manifest +hash before scratch reservation or checker execution. The activation binds +those exact facts to `workstream.artifact.materializer` plus `artifact.pre_submit.checker_input.materialize`. The uploader's project role grant does not substitute for this fixed-service authority, and no prepared -handle is serialized into a Celery message or durable record. +handle or scratch path is serialized into a Celery message or durable record. When a planned foundation action enters its ART adapter with an exact resource context, its bounded `action_unavailable` denial follows the same rollback-then-clean-restage diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index b91d4499c..14b327dfa 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -509,7 +509,7 @@ remain planned and unavailable, and add no migration. | `artifact.verification.execute` | `artifact.verification.execute` | fixed verifier service | verification job | `02D` | | `artifact.pending_work.scan` | `artifact.pending_work.scan` | fixed scheduler service | system pending-work scope | `02D` | | `artifact.put_attempt.resolve` | `artifact.put_attempt.resolve` | fixed put-resolver service | put attempt | `02D` | -| `artifact.pre_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | task plus current process-local prepared-bundle generation; no scratch path/handle is serialized | `04B2/04B3` | +| `artifact.pre_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | exact task, assignment, project/guide/snapshot and locked policy lineage plus plan/catalogue/archive/semantic-manifest identities and current process-local prepared generation; no scratch path/handle is serialized | `04B2/04B3` + `XINT-002-06A` | | `artifact.post_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | checker run and immutable bindings | `06A` | | `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | checker run | `06B` | | `artifact.review_packet.materialize` | `artifact.review_packet.materialize` | fixed materializer service | exact active lease and Submission packet | `07A` | @@ -517,7 +517,8 @@ remain planned and unavailable, and add no migration. The resource-owning chunk cells above identify ART hidden-behavior custody; they are distinct from the AUTH activation-custodian table and runtime -`ActionOwner`. XINT activation waves do not create new catalogue owner values. +`ActionOwner`. An approved XINT activation wave may take exact runtime owner +custody for its one action; it does not change ART product-behavior ownership. The fixed internal service identities and their complete action sets are also closed: @@ -539,13 +540,18 @@ closed: | `workstream.review.artifact_reference_reconciliation` | `review.artifact_reference.reconcile` | | `workstream.review.projection` | `review.projection.rebuild` | -The hidden 04B2 prepared resource binds task, assignment, project, effective +The hidden 04B2 prepared resource first locks fixed-service authority using +task, assignment, project, effective submission-artifact-policy ID, pre-submit checker-policy ID, process-local prepared generation, plan hash, catalogue-manifest hash, archive SHA-256/byte -count, and semantic-manifest hash. The fixed materializer consumes the opaque -prepared handle before any prepared-byte read, ZIP open, workspace reservation, -or checker fact. `AUTH_ART_04B` remains the catalogue owner; XINT-06A later -activates the action after hidden 04B3. ART does not activate it. +count, and storage scheme before ZIP inspection. The fixed materializer then +consumes the same opaque handle against those facts plus the server-computed +semantic-manifest hash before workspace reservation or checker execution. +`WS-XINT-002-06A` is the catalogue owner and activates the +action after merged ART-04B3; ART does not activate it. The ART-04C1 caller +must lock and revalidate the exact active assignment before preparing these +facts because `TaskAssignment` uses a new immutable ID for replacement rather +than a separate generation counter. `workstream.project.setup` was the eighth fixed identity when AUTH-12B merged; 02C expands the current registry to fourteen identities. AUTH-12E activates only