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 aabded1e4..ec7f36fee 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 @@ -3,7 +3,7 @@ | Chunk | Purpose | Risk | Dependency | |---|---|---|---| | `WS-XINT-002-01` | Reconcile the entire ART catalogue, permissions, owners, migration parity, and fixed-service matrix while every new action stays planned. | L1 | approved plan | -| `WS-XINT-002-02` | Extend PREP with closed feature-owned typed composition contracts and ART lock plans; activate nothing. | L1 | 01 | +| `WS-XINT-002-02` | Close the reusable PREP-to-ART operation interface: opaque prepared authority on durable mutation requests, no obsolete upload-session port, and no action activation. | L1 | 01 | | `WS-XINT-002-03` | Activate verifier, scheduler scan, and put resolver services from merged ART recovery evidence. | L1 | 02 plus ART 02C/02D evidence | | `WS-XINT-002-04` | Activate guide ingest, guide binding, and guide read in evidence-ordered substeps. | L1 | 02 plus ART 03A/03B evidence | | `WS-XINT-002-05A` | Activate initial contributor bundle preparation and durable ready admission. | L1 | 02 plus ART 04A-C evidence | @@ -17,3 +17,7 @@ Chunks 03-07 may be split only by the evidence boundaries named above. A split cannot add catalogue values, permissions, identities, matrix rows, or a second runtime protocol; such a discovery is contract drift and returns to planning. +Each activation chunk owns its exact feature resource contexts, non-forgeable +session/root-bound composer proof, lock order, stale-fact matrix, and crossed +race tests. Those facts must not be front-loaded into AUTH before the owning +feature behavior exists. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md index bffcd43b6..3f3387681 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md @@ -20,3 +20,9 @@ evidence-gated and cannot be eliminated safely. 9. The simple contribution loop applies: planning does not require a signed start/cancel event or merge-intent file. +10. WS-XINT-002-02 closes the reusable operation interface only. It does not + issue production capabilities for planned actions or invent feature facts + ahead of merged behavior. Exact session/root-bound feature composers, + resource contexts, locks, and race proof belong to chunks 03-07 and 05A-D. +11. Initiative status records durable merged facts and reviewed delivery order, + never transient “active” or “merge-pending” branch state. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/PLAN.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/PLAN.md index 3fbd3c247..91abf69e1 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/PLAN.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/PLAN.md @@ -6,7 +6,9 @@ Deliver the dependency in two front-loaded AUTH foundations followed by thin, evidence-gated activation waves: 1. Reconcile the complete v0.1 catalogue and static service matrix once. -2. Extend PREP once with closed typed feature integration contracts. +2. Close the PREP-to-ART operation interface once: durable mutation requests + carry opaque prepared authority, while exact feature contexts remain with + their evidence-backed activation chunks. 3. Activate fixed internal recovery services before any durable provider work. 4. Activate guide ingest/use only after the matching hidden ART behavior. 5. Activate initial contributor preparation, then atomic Submission/binding @@ -62,7 +64,7 @@ initial submission; checker remediation rooted in the exact final `needs_revision` CheckerRun; and human-review revision rooted in the exact revision obligation. Checker remediation records the server-derived `remediation_source_checker_run_id`, immediate same-task predecessor, existing -locked task context, and current `allow_review`; it has no ReviewFinding +locked task context; it has no inherited `allow_review`, ReviewFinding response, revision preparation, human revision deadline/round consumption, reviewer contribution, or synthetic human actor. @@ -85,9 +87,13 @@ replaced-transaction, revoked, stale, or already consumed handles deny. ## Ownership rule AUTH evaluates authority but does not load feature rows or encode product -lifecycle. Feature modules expose closed typed composers/loaders and own their -locks and invariants. ART orchestrates bytes and receives only opaque prepared -handles plus typed decisions; it never imports AUTH repositories. +lifecycle. Chunk 02 closes durable ART mutation ports around the existing +opaque prepared handle and removes the obsolete upload-session interface. +Each activation chunk then defines its closed typed composer/loader in the +owning feature module, binds its proof to the same session and root +transaction, and owns its locks and invariants. ART orchestrates bytes and +receives only opaque prepared handles plus typed decisions; it never imports +AUTH repositories. ## Verification diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md index 9b5d2b6dd..1f6d8167e 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md @@ -1,9 +1,16 @@ # Status: WS-XINT-002 ART-AUTH End-to-End Contract -`WS-XINT-002-01` is implemented and merge-pending. It reconciles the closed ART -catalogue and fixed-service matrix without activating any action, evaluator, -route, command, grant, or lifecycle behavior. The dirty ART-03A worktree remains -preserved and untouched. +## Durable completed state -Next same-initiative gate after merge and a new explicit start: -`WS-XINT-002-02`. +- `WS-XINT-002-PLAN` is merged. +- `WS-XINT-002-01` is merged on `main` at merge commit `89956cff`; the closed + ART catalogue and fixed-service matrix are reconciled without activating any + new action. + +This file records only durable merged state and reviewed delivery order. It +does not describe a branch as “in progress”, “merge-pending”, or “active”; +GitHub branches and pull requests are the source of truth for transient work. +That rule prevents a merged PR from carrying stale pre-merge prose onto +`main`. The durable successor order remains in `CHUNK_MAP.md`; this status file +does not duplicate a transient “next chunk” pointer that becomes stale at +merge. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-02-prepared-feature-boundaries.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-02-prepared-feature-boundaries.md index bdacb9394..90efdefdd 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-02-prepared-feature-boundaries.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-02-prepared-feature-boundaries.md @@ -1,9 +1,19 @@ -# Chunk Contract: WS-XINT-002-02 Prepared Feature Boundaries +# Chunk Contract: WS-XINT-002-02 Prepared Operation Boundaries ## Goal -Extend the existing opaque transaction-local PREP protocol once for all durable -ART boundaries using closed typed feature-owned composition contracts. +Close the reusable PREP-to-ART operation interface without activating an ART +action or pretending that unmerged feature rows can already be composed. + +This chunk preserves the existing opaque transaction-local PREP mechanism, +removes the obsolete upload-session interface, and makes every declared +durable ART mutation request carry opaque prepared authority instead of a raw +request authentication context. Exact feature-owned row composers and final +resource contexts remain owned by their evidence-backed activation chunks. + +## Base + +Reviewed against `main` merge commit `89956cff`. ## Risk class @@ -12,17 +22,15 @@ L1. ## Allowed files ```text -backend/app/modules/authorization/kernel.py backend/app/modules/authorization/prepared.py -backend/app/modules/authorization/runtime.py -backend/app/modules/authorization/repository.py backend/app/interfaces/artifact_operations.py -backend/app/modules/artifacts/authorization.py backend/tests/test_authorization.py -backend/tests/test_auth.py -backend/tests/test_artifact_authorization.py +backend/tests/test_artifact_architecture.py docs/spec_authorization_service.md docs/spec_artifact_storage_service.md +.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/PLAN.md +.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md +.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-02-prepared-feature-boundaries.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-*.md @@ -30,57 +38,83 @@ docs/spec_artifact_storage_service.md ## Not allowed -- action activation or catalogue/migration changes; +- action activation, catalogue, migration, evaluator, kernel, repository, + route, command, provider, durable-write, or product-lifecycle changes; - feature repository imports in AUTH, caller callbacks, open dictionaries, - generic service locators, or a second capability implementation; -- provider I/O, ART durable writes, or product lifecycle mutation. + generic service locators, caller-asserted feature facts, or a second + capability implementation; +- production handle issuance for a planned ART action; +- defining task, assignment, CheckerRun, review lease, finding/response, + predecessor, revision-obligation, or advancement-fence truth before its + owning feature chunk merges. + +## Closed interface changes + +- Remove `ContributorArtifactUploadPort` and every upload-session request from + the live interface; there is no compatibility alias. +- Add `SubmissionBundlePreparationPort.prepare` with one closed + `SubmissionBundlePreparationRequest` for the outer ZIP. +- Replace `ReadyUploadSetRequest` with process-local + `PreparedBundleMaterializationRequest`. +- Guide ingest, submission preparation, verified binding, prepared-bundle and + binding materialization, and checker-output write requests carry an exact + `PreparedAuthorizationHandle`; they do not accept `AuthorizationContext` as + mutation authority. +- Each typed mutation method maps to one closed expected `ActionId`; guide, + submission, and checker-output binding use separate request types with their + exact owning selectors. Requests carry no caller-selected action, generic + resource selector, or facts map. +- Operator read/recovery requests remain unchanged; their existing bounded + authorization path is outside this durable mutation interface cut. +- Review-packet materialization and review-evidence binding are intentionally + deferred to WS-XINT-002-07. They require merged REV lease/evidence-slot facts + and will extend the same typed prepared-operation convention without adding a + second capability protocol or a generic materialization/binding escape hatch. ## Acceptance criteria -- Define closed typed contexts and authority plans for guide ingest, submission - preparation/create, artifact binding, checker materialization/output, review - packet, and review evidence binding. -- Preserve exact service/session/root-transaction/action/actor/scope/key/digest - binding, opacity, non-copyability, non-serialization, and single use. -- Human plans lock exact actor/link plus effective project grant/assignment; - service plans lock exact profile/link and validate immutable identity, matrix, - and availability. -- Feature modules own row loading/locking and final context composition through - typed ports; AUTH owns no feature repository and accepts no caller assertion - as authority. -- Initial, checker-remediation, and human-review revision contexts are closed - variants. Checker remediation binds the final `needs_revision` CheckerRun, - server-derived remediation source, immediate predecessor, locked task context, - and current `allow_review`, without human-review facts. Revision binds exact - predecessor, preparation head/digest, obligation/findings/responses, - replacement assignment, limits, deadline, and advancement fence. -- Consume stages one final decision in the caller transaction; denial and any - participant failure roll back with no reusable handle. The service-local - issuance registry burns the handle before evaluation outside database - rollback semantics; denial, evidence failure, participant failure, caller - rollback, timeout, or cancellation cannot restore it. -- PostgreSQL tests cover revoke/suspend, wrong action/resource/session/service, - replay/concurrent consume, transaction replacement, stale feature facts, and - evidence failure. Each rollback class retries the identical handle and proves - rejection before authority evaluation, evidence, or participant mutation. -- No planned ART action becomes executable in this chunk. +- Existing PREP remains bound to the exact service instance, session, root + transaction, action, actor, scope, idempotency key, and canonical request + digest; handles remain opaque, non-copyable, non-serializable, and single-use. +- Production `prepare()` issues no handle or evidence for every planned ART + action in this chunk. Matrix membership is checked first: a wrong fixed + service receives `permission_not_granted`; the owning fixed service reaches + planned availability and receives `action_unavailable`. +- Denial, evidence failure, participant failure, caller rollback, commit + failure, timeout, or cancellation burns an issued handle outside database + rollback semantics. Existing failure tests must retry the identical handle + and prove rejection before a second kernel evaluation or evidence attempt. +- The declared durable ART mutation ports contain no `AuthorizationContext`, + upload-session method, upload-session identifier, or caller-selected generic + resource/facts mapping. +- Static tests prove the exact exported interface names and reject reintroduced + raw-context or upload-session authority paths. +- `PreparedAuthorizationHandle` appears only on process-local, non-Pydantic ART + mutation request objects. Static tests reject it in route schemas, outbox or + Celery payloads, provider interfaces, and any serializable public contract. +- Exact final contexts and non-forgeable feature proofs are deliberately not + invented here. Chunks 03-07 and 05A-D must define them in the owning feature + modules, bind them to the same session/root transaction, lock their exact + rows, and test stale/cross-session/cross-root/direct-construction bypasses + before the corresponding action is activated. +- No planned ART action becomes executable. ## Verification ```bash (cd backend && .venv/bin/python -m ruff check app tests scripts) -(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_auth.py tests/test_artifact_authorization.py -q --cov=app.modules.authorization --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_artifact_architecture.py -q --cov=app.modules.authorization.prepared --cov-report=term-missing --cov-fail-under=90) python3 scripts/check_markdown_links.py python3 scripts/check_stale_authorization_docs.py python3 scripts/check_stale_artifact_contracts.py +python3 scripts/check_stale_workstream_wording.py git diff --check ``` -The exact PR head must pass GitHub checks `Backend / test` and -`Agent Gates / agent-gates`, preserving the 78 percent global and 90 percent -materially changed subsystem coverage floors. - -Full backend coverage runs in GitHub Actions. +The focused coverage command measures the materially changed PREP subsystem; +it must remain at or above 90 percent. The exact PR head must also pass GitHub +checks `Backend / test` and `Agent Gates / agent-gates`, preserving the 78 +percent repository-wide floor. Full backend coverage runs in GitHub Actions. ## Required reviewers @@ -89,5 +123,6 @@ integrity, docs, reuse/dedup, and test delta. ## Human review focus -No feature truth in AUTH, no caller-asserted authority, exact lock order, and -atomic evidence/mutation semantics. +No raw authentication context as durable mutation authority, no obsolete +upload-session interface, no planned-action handle, and no premature feature +truth in AUTH. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-external-review-response.md new file mode 100644 index 000000000..d79ce0f3c --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-external-review-response.md @@ -0,0 +1,39 @@ +# WS-XINT-002-02 External Review Response + +Date: 2026-07-27 + +## Comments addressed + +- CodeRabbit correctly observed that the exhaustive + `PreparedBundleMaterializationRequest` prose omitted its task and assignment + selectors. The spec now names both fields. + +## Comments deferred + +- CodeRabbit's generic docstring-coverage warning is not actionable: the exact + hosted repository Docstring Coverage step passed before the Backend job + reached semantic tests. No coverage threshold or unrelated docstring was + changed. + +## GitHub checks + +- Agent Gates: passed. +- Backend first run: one pre-existing PostgreSQL lock-observation test timed out + under four-lane load after the other 1,596 shared-foundation tests passed. + The evidence validator failed closed and interrupted the remaining lanes. + Reruns use the same exact PR head without changing a gate. GitHub checks, + rather than committed prose, own transient rerun and merge-readiness state. + +## Human decisions needed + +None. + +## Commands rerun + +- Markdown link and stale documentation checks after the CodeRabbit fix. +- GitHub Backend failed-job rerun on the exact PR head. + +## Remaining risks + +The PR's exact-head Backend and Agent Gates checks must pass before merge; their +live state remains in GitHub and is not duplicated in this durable record. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-internal-review.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-internal-review.md new file mode 100644 index 000000000..78b04d8e1 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-internal-review.md @@ -0,0 +1,48 @@ +# WS-XINT-002-02 Internal Review + +Date: 2026-07-27 + +## Evidence gate + +PASS. + +- Scope: 10 implementation/planning/spec files inside the chunk contract before + this review record; no workflow, dependency, migration, evaluator, route, + provider, or durable-write change. +- Ruff: `ruff check app tests scripts` passed. +- PostgreSQL: 364 authorization/architecture tests passed; PREP coverage was + 98.32 percent. The final reviewer-driven rollback assertions then passed the + isolated PostgreSQL atomicity test again. +- Architecture: all 11 artifact architecture tests passed after final changes. +- Documentation: markdown links and stale authorization, artifact-contract, + and Workstream-wording checks passed. +- Integrity: no skipped tests, lowered thresholds, CI bypass, or new dependency. + GitHub owns the full repository coverage run at the exact PR head. + +## Reviewer results + +- Senior engineering: PASS after module-router and complete signature leak + guards were added. +- Architecture: PASS after provider-interface coverage and action-specific + guide/submission/checker binding requests replaced the generic request. +- QA/test: PASS after wrong-service ordering and rollback no-reentry proof were + completed. +- Security/auth: PASS; planned actions issue no handle or evidence, and matrix + denial precedes planned availability. +- Product/ops: PASS after exact guide setup-generation ownership and the + unchanged Operator recovery boundary were made explicit. +- CI integrity: PASS; hosted 78 percent global and 90 percent subsystem gates + remain unchanged. +- Docs: PASS after stale binding names and premature review lookup vocabulary + were corrected. +- Test delta: PASS after every failure/cancellation retry used the zero-reentry + helper and mutation protocols rejected parameter escape hatches. +- Reuse/dedup: PASS WITH LOW RISK. The older single-action planned-denial test + overlaps the new exhaustive parametrized test. It remains as a focused + regression for row refresh; no production duplication or second protocol + exists. + +## Findings resolved + +All High and Medium findings were fixed and re-reviewed. No blocking finding +remains. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-pr-trust-bundle.md new file mode 100644 index 000000000..9e486e893 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-02-pr-trust-bundle.md @@ -0,0 +1,84 @@ +# WS-XINT-002-02 PR Trust Bundle + +## Chunk + +`WS-XINT-002-02` — Prepared Operation Boundaries (L1). + +## Goal and human-approved intent + +Close the reusable PREP-to-ART interface without activating any planned ART +action. Prevent the stale status wording that previously reached `main`. + +## What changed and why + +- Durable ART mutation requests now carry a process-local opaque + `PreparedAuthorizationHandle`, not raw request authentication context. +- The obsolete upload-session port was removed with no compatibility alias. +- Guide, submission, and checker-output binding use distinct request types and + typed methods, preventing action and resource confusion. +- Static guards reject prepared handles in routes, schemas, async payloads, + provider/public interfaces, and unauthorized method signatures. +- PREP tests prove every planned ART action issues no handle/evidence, a wrong + fixed service denies before availability, and all rollback/cancellation + classes burn the handle without kernel or evidence reentry. +- Initiative status now records durable merged facts only and never duplicates + transient active, merge-pending, or next-chunk state. + +## Design and alternatives + +Chosen: one existing PREP capability protocol plus closed operation-specific +requests. Exact feature row composers, locks, and resource contexts remain in +their evidence-backed activation chunks. + +Rejected: generic binding requests, caller-selected actions/facts, raw +`AuthorizationContext` mutation authority, a second capability protocol, +premature feature truth, and compatibility upload-session aliases. + +## Scope and product behavior + +No action activation, catalogue/migration, evaluator, kernel, repository, +route, provider I/O, durable write, or lifecycle change. Operator read/recovery +remains on its existing bounded path. Review packet/evidence work remains +deferred to `WS-XINT-002-07`. + +## Acceptance proof and checks + +- Isolated PostgreSQL authorization/architecture run: 364 passed. +- PREP coverage: 98.32 percent, above the 90 percent changed-subsystem floor. +- Final isolated PostgreSQL rollback atomicity regression: passed. +- Artifact architecture: 11 passed. +- Full Ruff, markdown links, stale authorization docs, stale artifact + contracts, stale Workstream wording, and `git diff --check`: passed. +- No CI/config/dependency changes, skips, xfails, bypasses, or lowered gates. +- GitHub `Backend / test` and `Agent Gates / agent-gates` remain required for + full exact-head coverage. + +## Reviewer results + +Senior, architecture, QA, security, product/ops, CI integrity, docs, and test +delta: PASS. Reuse/dedup: PASS WITH LOW RISK for one intentionally retained +focused test overlapping the new exhaustive regression. + +## External review + +CodeRabbit's one actionable documentation comment was fixed: the exhaustive +prepared-bundle materialization description now includes task and assignment +context. Its docstring warning was not reproduced by the repository gate; the +hosted Docstring Coverage step passed. Agent Gates passed. The first Backend +run encountered one pre-existing lock-observation timeout under four-lane load +after 1,596 tests in that lane passed. Transient rerun and merge-readiness state +is intentionally not copied here; the PR's exact-head GitHub checks are the +source of truth. + +## Remaining risks and follow-up + +This chunk intentionally activates nothing. Chunks 03-07 and 05A-D must supply +their exact non-forgeable feature proofs, lock order, stale/race tests, and +activation evidence. Full repository coverage is delegated to GitHub Actions. + +## Human review focus and merge ownership + +Confirm no raw authentication context or generic resource/action selector can +enter durable ART mutation ports, no planned action issues a handle, and the +status file cannot become stale at merge. The user retains merge approval for +this specific PR. diff --git a/backend/app/interfaces/artifact_operations.py b/backend/app/interfaces/artifact_operations.py index b86cdb91b..30a17b0b3 100644 --- a/backend/app/interfaces/artifact_operations.py +++ b/backend/app/interfaces/artifact_operations.py @@ -7,13 +7,13 @@ from typing import Literal, Protocol from uuid import UUID -from app.modules.artifacts.sources import ArtifactCommitment +from app.modules.artifacts.sources import ArtifactCommitment, PreparedArtifact +from app.modules.authorization.prepared import PreparedAuthorizationHandle from app.modules.authorization.runtime import AuthorizationContext __all__ = ( "ArtifactAuditResourceType", "ArtifactBindingResourceType", - "ArtifactBindingCreateRequest", "ArtifactBindingPort", "ArtifactMaterializationPort", "ArtifactOperatorReadPort", @@ -21,11 +21,15 @@ "ArtifactRecoveryRequest", "BindingMaterializationRequest", "CheckerArtifactOutputPort", + "CheckerOutputBindingRequest", "CheckerOutputArtifactRequest", - "ContributorArtifactUploadPort", "GuideArtifactIngestPort", "GuideArtifactIngestRequest", - "ReadyUploadSetRequest", + "GuideSourceBindingRequest", + "PreparedBundleMaterializationRequest", + "SubmissionBundlePreparationPort", + "SubmissionBundlePreparationRequest", + "SubmissionBindingRequest", ) ArtifactBindingResourceType = Literal[ @@ -49,9 +53,9 @@ @dataclass(frozen=True, slots=True) class GuideArtifactIngestRequest: - """Authorized guide-source bytes and their canonical product ownership.""" + """Prepared guide-source authority and canonical product ownership.""" - authorization_context: AuthorizationContext + prepared_authorization: PreparedAuthorizationHandle project_id: UUID guide_source_snapshot_id: UUID source_item_id: UUID @@ -61,34 +65,71 @@ class GuideArtifactIngestRequest: @dataclass(frozen=True, slots=True) -class ArtifactBindingCreateRequest: - """Verified content and exact product facts for immutable binding.""" +class GuideSourceBindingRequest: + """Verified guide content and its exact setup-generation owner.""" - authorization_context: AuthorizationContext + prepared_authorization: PreparedAuthorizationHandle + project_id: UUID + guide_source_snapshot_id: UUID + source_item_id: UUID + project_setup_run_id: UUID + logical_role: str + verified_content_ids: tuple[UUID, ...] + + +@dataclass(frozen=True, slots=True) +class SubmissionBindingRequest: + """Verified contributor content and its exact Submission owner.""" + + prepared_authorization: PreparedAuthorizationHandle project_id: UUID task_id: UUID - submission_id: UUID | None - checker_run_id: UUID | None + submission_id: UUID logical_role: str verified_content_ids: tuple[UUID, ...] @dataclass(frozen=True, slots=True) -class ReadyUploadSetRequest: - """One sealed upload set and its locked task policy context.""" +class CheckerOutputBindingRequest: + """Verified checker output and its exact CheckerRun owner.""" - authorization_context: AuthorizationContext + prepared_authorization: PreparedAuthorizationHandle + project_id: UUID + task_id: UUID + submission_id: UUID + checker_run_id: UUID + logical_role: str + verified_content_ids: tuple[UUID, ...] + + +@dataclass(frozen=True, slots=True) +class SubmissionBundlePreparationRequest: + """One prepared contributor authority and continuous outer ZIP source.""" + + prepared_authorization: PreparedAuthorizationHandle task_id: UUID - sealed_upload_session_id: UUID + assignment_id: UUID + byte_source: AsyncIterable[bytes] + client_commitment: ArtifactCommitment | None = None + + +@dataclass(frozen=True, slots=True) +class PreparedBundleMaterializationRequest: + """Process-local prepared bytes and exact policy selectors.""" + + prepared_authorization: PreparedAuthorizationHandle + task_id: UUID + assignment_id: UUID submission_artifact_policy_id: UUID checker_policy_id: UUID + prepared_artifact: PreparedArtifact @dataclass(frozen=True, slots=True) class BindingMaterializationRequest: """Immutable bindings selected by exact execution context.""" - authorization_context: AuthorizationContext + prepared_authorization: PreparedAuthorizationHandle task_id: UUID submission_id: UUID | None checker_run_id: UUID @@ -99,7 +140,7 @@ class BindingMaterializationRequest: class CheckerOutputArtifactRequest: """Generated checker bytes bound to one fixed service execution.""" - service_actor_context: AuthorizationContext + prepared_authorization: PreparedAuthorizationHandle task_id: UUID submission_id: UUID checker_run_id: UUID @@ -128,72 +169,34 @@ async def ingest(self, request: GuideArtifactIngestRequest) -> object: """Ingest one canonical guide source item.""" -class ContributorArtifactUploadPort(Protocol): - """Own the closed contributor upload-session lifecycle.""" +class SubmissionBundlePreparationPort(Protocol): + """Prepare one continuous contributor bundle without upload sessions.""" - async def create( - self, - *, - authorization_context: AuthorizationContext, - task_id: UUID, - ) -> object: - """Create one authorized task upload session.""" + async def prepare(self, request: SubmissionBundlePreparationRequest) -> object: + """Prepare one authorized outer ZIP in bounded private scratch.""" - async def read( - self, - *, - authorization_context: AuthorizationContext, - task_id: UUID, - upload_session_id: UUID, - ) -> object: - """Read one authorized upload session.""" - async def write( - self, - *, - authorization_context: AuthorizationContext, - task_id: UUID, - upload_session_id: UUID, - logical_role: str, - byte_source: AsyncIterable[bytes], - client_commitment: ArtifactCommitment | None = None, - ) -> object: - """Write one bounded item through artifact orchestration.""" - - async def seal( - self, - *, - authorization_context: AuthorizationContext, - task_id: UUID, - upload_session_id: UUID, - ) -> object: - """Seal one exact upload set.""" - - async def cancel( - self, - *, - authorization_context: AuthorizationContext, - task_id: UUID, - upload_session_id: UUID, - ) -> None: - """Cancel one unsealed upload session.""" +class ArtifactBindingPort(Protocol): + """Create exact action-bound bindings from verified content.""" + async def bind_guide_source(self, request: GuideSourceBindingRequest) -> object: + """Bind verified guide content under the guide binding action.""" -class ArtifactBindingPort(Protocol): - """Create bindings only from orchestrator-verified content.""" + async def bind_submission(self, request: SubmissionBindingRequest) -> object: + """Bind verified submission content under the submission binding action.""" - async def bind_verified(self, request: ArtifactBindingCreateRequest) -> object: - """Bind exact verified content to canonical product facts.""" + async def bind_checker_output(self, request: CheckerOutputBindingRequest) -> object: + """Bind verified checker output under the checker binding action.""" class ArtifactMaterializationPort(Protocol): """Materialize only the two canonical immutable source forms.""" - async def materialize_ready_upload_set( + async def materialize_prepared_bundle( self, - request: ReadyUploadSetRequest, + request: PreparedBundleMaterializationRequest, ) -> object: - """Materialize one sealed upload set whose items are ready.""" + """Materialize one process-local prepared bundle generation.""" async def materialize_bindings( self, diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index 0f6c36c66..76c704038 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -15,7 +15,7 @@ S3_ADAPTER_MODULE = APP_ROOT / "adapters" / "artifacts" / "s3_compatible.py" CLOSED_PORTS = { "GuideArtifactIngestPort", - "ContributorArtifactUploadPort", + "SubmissionBundlePreparationPort", "ArtifactBindingPort", "ArtifactMaterializationPort", "CheckerArtifactOutputPort", @@ -24,8 +24,11 @@ } CANONICAL_REQUESTS = { "GuideArtifactIngestRequest", - "ArtifactBindingCreateRequest", - "ReadyUploadSetRequest", + "GuideSourceBindingRequest", + "SubmissionBindingRequest", + "CheckerOutputBindingRequest", + "SubmissionBundlePreparationRequest", + "PreparedBundleMaterializationRequest", "BindingMaterializationRequest", "CheckerOutputArtifactRequest", "ArtifactRecoveryRequest", @@ -34,6 +37,14 @@ "ArtifactAuditResourceType", "ArtifactBindingResourceType", } +PREPARED_MUTATION_REQUESTS = CANONICAL_REQUESTS - {"ArtifactRecoveryRequest"} +PREPARED_HANDLE_FORBIDDEN_ROOTS = ( + APP_ROOT / "adapters", + APP_ROOT / "api", + APP_ROOT / "modules" / "outbox", + APP_ROOT / "schemas", + APP_ROOT / "workers", +) RAW_TYPES = {"ArtifactStore", "ArtifactStorageOrchestrator"} INTERNAL_ADMISSION_TYPES = { "ArtifactAdmissionService", @@ -60,17 +71,32 @@ def _tree(path: Path) -> ast.Module: def _annotation_names(annotation: ast.expr | None) -> set[str]: if annotation is None: return set() - return { - node.id - for node in ast.walk(annotation) - if isinstance(node, ast.Name) - } | { - node.attr - for node in ast.walk(annotation) - if isinstance(node, ast.Attribute) + return {node.id for node in ast.walk(annotation) if isinstance(node, ast.Name)} | { + node.attr for node in ast.walk(annotation) if isinstance(node, ast.Attribute) } +def _declared_annotation_names(tree: ast.AST) -> set[str]: + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.AnnAssign): + names.update(_annotation_names(node.annotation)) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + arguments = ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ) + for argument in arguments: + names.update(_annotation_names(argument.annotation)) + if node.args.vararg is not None: + names.update(_annotation_names(node.args.vararg.annotation)) + if node.args.kwarg is not None: + names.update(_annotation_names(node.args.kwarg.annotation)) + names.update(_annotation_names(node.returns)) + return names + + def test_product_api_and_workers_cannot_import_or_inject_raw_artifact_types() -> None: product_modules = [ path @@ -85,10 +111,11 @@ def test_product_api_and_workers_cannot_import_or_inject_raw_artifact_types() -> imported = {alias.name for alias in node.names} forbidden = imported & (RAW_TYPES | INTERNAL_ADMISSION_TYPES) if forbidden: - violations.append(f"{path.relative_to(BACKEND_ROOT)} imports {sorted(forbidden)}") - if ( - node.module == "app.modules.artifacts.service" - and any(alias.name == "*" for alias in node.names) + violations.append( + f"{path.relative_to(BACKEND_ROOT)} imports {sorted(forbidden)}" + ) + if node.module == "app.modules.artifacts.service" and any( + alias.name == "*" for alias in node.names ): violations.append( f"{path.relative_to(BACKEND_ROOT)} imports broad artifact services" @@ -120,8 +147,7 @@ def test_only_artifact_orchestrator_owns_provider_execution() -> None: ( node for node in tree.body - if isinstance(node, ast.ClassDef) - and node.name == "ArtifactStorageOrchestrator" + if isinstance(node, ast.ClassDef) and node.name == "ArtifactStorageOrchestrator" ), None, ) @@ -141,9 +167,7 @@ def test_only_artifact_orchestrator_owns_provider_execution() -> None: ) ) ): - violations.append( - f"{path.relative_to(BACKEND_ROOT)} calls {node.func.attr}" - ) + violations.append(f"{path.relative_to(BACKEND_ROOT)} calls {node.func.attr}") assert violations == [] @@ -152,18 +176,14 @@ def test_artifact_domain_does_not_import_adapter_modules() -> None: violations: list[str] = [] for path in _python_files(APP_ROOT / "modules" / "artifacts"): for node in ast.walk(_tree(path)): - if isinstance(node, ast.ImportFrom) and ( - node.module or "" - ).startswith("app.adapters.artifacts"): - violations.append( - f"{path.relative_to(BACKEND_ROOT)} imports {node.module}" - ) + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith( + "app.adapters.artifacts" + ): + violations.append(f"{path.relative_to(BACKEND_ROOT)} imports {node.module}") if isinstance(node, ast.Import): for alias in node.names: if alias.name.startswith("app.adapters.artifacts"): - violations.append( - f"{path.relative_to(BACKEND_ROOT)} imports {alias.name}" - ) + violations.append(f"{path.relative_to(BACKEND_ROOT)} imports {alias.name}") assert violations == [] @@ -342,6 +362,103 @@ def test_artifact_operations_exports_only_canonical_closed_contracts() -> None: } +def test_durable_artifact_mutation_ports_require_process_local_prepared_authority() -> None: + tree = _tree(ARTIFACT_OPERATIONS) + request_classes = { + node.name: node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name in PREPARED_MUTATION_REQUESTS + } + assert set(request_classes) == PREPARED_MUTATION_REQUESTS + for name, class_node in request_classes.items(): + assert not any( + isinstance(base, ast.Name) and base.id == "BaseModel" for base in class_node.bases + ), name + fields = { + node.target.id: _annotation_names(node.annotation) + for node in class_node.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + } + assert fields.get("prepared_authorization") == {"PreparedAuthorizationHandle"}, name + assert all("AuthorizationContext" not in types for types in fields.values()), name + assert {"action_id", "resource_context", "facts"}.isdisjoint(fields), name + + source = ARTIFACT_OPERATIONS.read_text(encoding="utf-8") + assert "upload_session" not in source + assert "ContributorArtifactUploadPort" not in source + assert "ReadyUploadSetRequest" not in source + assert "ActionId" not in source + + expected_methods = { + "GuideArtifactIngestPort": {"ingest"}, + "SubmissionBundlePreparationPort": {"prepare"}, + "ArtifactBindingPort": { + "bind_guide_source", + "bind_submission", + "bind_checker_output", + }, + "ArtifactMaterializationPort": { + "materialize_prepared_bundle", + "materialize_bindings", + }, + "CheckerArtifactOutputPort": {"store"}, + } + expected_request_by_method = { + "ingest": "GuideArtifactIngestRequest", + "prepare": "SubmissionBundlePreparationRequest", + "bind_guide_source": "GuideSourceBindingRequest", + "bind_submission": "SubmissionBindingRequest", + "bind_checker_output": "CheckerOutputBindingRequest", + "materialize_prepared_bundle": "PreparedBundleMaterializationRequest", + "materialize_bindings": "BindingMaterializationRequest", + "store": "CheckerOutputArtifactRequest", + } + protocols = { + node.name: node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name in expected_methods + } + for name, methods in expected_methods.items(): + declared_methods = { + node.name + for node in protocols[name].body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + assert declared_methods == methods + for node in protocols[name].body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + assert node.args.posonlyargs == [] + assert [argument.arg for argument in node.args.args] == ["self", "request"] + assert node.args.kwonlyargs == [] + assert node.args.vararg is None + assert node.args.kwarg is None + request_argument = node.args.args[-1] + assert _annotation_names(request_argument.annotation) == { + expected_request_by_method[node.name] + } + assert "AuthorizationContext" not in _declared_annotation_names(node) + + +def test_prepared_handle_never_enters_public_async_or_provider_contracts() -> None: + violations: list[str] = [] + schema_files = tuple(APP_ROOT.glob("modules/**/schemas.py")) + route_files = tuple(APP_ROOT.glob("modules/**/router.py")) + provider_interface_files = tuple( + path for path in _python_files(APP_ROOT / "interfaces") if path != ARTIFACT_OPERATIONS + ) + for path in ( + _python_files(*PREPARED_HANDLE_FORBIDDEN_ROOTS) + + schema_files + + route_files + + provider_interface_files + ): + names = _declared_annotation_names(_tree(path)) + if "PreparedAuthorizationHandle" in names: + violations.append(str(path.relative_to(BACKEND_ROOT))) + assert violations == [] + + def test_scratch_cleanup_worker_has_no_product_or_database_state() -> None: path = APP_ROOT / "workers" / "artifacts.py" forbidden_import_prefixes = ( @@ -356,9 +473,7 @@ def test_scratch_cleanup_worker_has_no_product_or_database_state() -> None: imported_modules.add(node.module) elif isinstance(node, ast.Import): imported_modules.update(alias.name for alias in node.names) - assert not any( - module.startswith(forbidden_import_prefixes) for module in imported_modules - ) + assert not any(module.startswith(forbidden_import_prefixes) for module in imported_modules) def test_artifact_repository_does_not_own_actor_persistence() -> None: diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index a0bd80a6a..d2ef015eb 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2802,6 +2802,7 @@ def _runtime_context( actor_status: ActorStatus = ActorStatus.ACTIVE, link_status: IdentityLinkStatus = IdentityLinkStatus.ACTIVE, actor_kind: ActorKind = ActorKind.HUMAN, + service_identity: ServiceIdentity = ServiceIdentity.ARTIFACT_VERIFIER, ) -> AuthorizationContext: context_type = ( ServiceAuthorizationContext @@ -2809,9 +2810,7 @@ def _runtime_context( else HumanAuthorizationContext ) service_fields = ( - {"service_identity": ServiceIdentity.ARTIFACT_VERIFIER} - if actor_kind is ActorKind.SERVICE - else {} + {"service_identity": service_identity} if actor_kind is ActorKind.SERVICE else {} ) return context_type( actor_profile_id=uuid4(), @@ -3630,6 +3629,130 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): assert evidence.events == [] +@pytest.mark.parametrize( + ("action_id", "service_identity"), + tuple( + ( + definition.action_id, + next( + ( + identity + for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() + if definition.action_id in actions + ), + None, + ), + ) + for definition in ACTION_DEFINITIONS + if definition.availability is ActionAvailability.PLANNED + and definition.action_id.value.startswith("artifact.") + ), +) +@pytest.mark.asyncio +async def test_prepared_issues_no_handle_or_evidence_for_every_planned_art_action( + action_id: ActionId, + service_identity: ServiceIdentity | None, +): + context = _runtime_context( + actor_kind=ActorKind.SERVICE if service_identity is not None else ActorKind.HUMAN, + service_identity=service_identity or ServiceIdentity.ARTIFACT_VERIFIER, + ) + + class LockedFacts: + calls = 0 + + async def lock_request_actor(self, identity_link_id, actor_profile_id): + self.calls += 1 + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status="active", + ), + SimpleNamespace( + id=str(actor_profile_id), + actor_kind=context.actor_kind.value, + status="active", + service_identity=( + service_identity.value if service_identity is not None else None + ), + ), + ) + + session = _PreparedTestSession() + authorization, evidence = _runtime_service(context, session=session) + facts = LockedFacts() + authorization._admin = facts # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + facts, # type: ignore[arg-type] + ) + with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: + await prepared.prepare( + action_id, + PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}), + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + assert exc_info.value.denial_code is AuthorizationDenialCode.ACTION_UNAVAILABLE + assert facts.calls == (1 if service_identity is not None else 0) + assert prepared._issued == {} + assert evidence.events == [] + + +@pytest.mark.parametrize( + ("action_id", "owning_identity"), + tuple( + (action_id, identity) + for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() + for action_id in actions + if action_id.value.startswith("artifact.") + and next( + definition for definition in ACTION_DEFINITIONS if definition.action_id is action_id + ).availability + is ActionAvailability.PLANNED + ), +) +@pytest.mark.asyncio +async def test_prepared_wrong_fixed_service_denies_before_planned_availability( + action_id: ActionId, + owning_identity: ServiceIdentity, +): + wrong_identity = next( + identity + for identity in ServiceIdentity + if identity is not owning_identity + and action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(identity, frozenset()) + ) + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + service_identity=wrong_identity, + ) + session = _PreparedTestSession() + authorization, evidence = _runtime_service(context, session=session) + facts = _PreparedAdminFacts(context) + authorization._admin = facts # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + facts, + ) + + with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: + await prepared.prepare( + action_id, + PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}), + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + + assert exc_info.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert facts.calls == 0 + assert prepared._issued == {} + assert evidence.events == [] + + @pytest.mark.asyncio async def test_prepared_rejects_unsupported_scope_missing_grant_and_inactive_root(): context = _runtime_context() @@ -3744,6 +3867,41 @@ async def test_prepared_postgresql_failure_and_cancellation_are_atomic( requested_fields=("display_name",), ) + async def assert_handle_burned_without_reentry( + handle: PreparedAuthorizationHandle, + caller_input: PreparedAuthorizationInput, + ) -> None: + require_calls = 0 + evidence_calls = 0 + original_require = authorization._require_prelocked + original_add_event = authorization._audit.add_authority_event + + async def counted_require(*args, **kwargs): + nonlocal require_calls + require_calls += 1 + return await original_require(*args, **kwargs) + + async def counted_add_event(event): + nonlocal evidence_calls + evidence_calls += 1 + return await original_add_event(event) + + authorization._require_prelocked = counted_require # type: ignore[method-assign] + authorization._audit.add_authority_event = counted_add_event # type: ignore[method-assign] + try: + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, + ActionId.ACTOR_PROFILE_UPDATE_SELF, + caller_input, + resource, + ) + finally: + authorization._require_prelocked = original_require # type: ignore[method-assign] + authorization._audit.add_authority_event = original_add_event # type: ignore[method-assign] + assert require_calls == 0 + assert evidence_calls == 0 + success_input = PreparedAuthorizationInput( idempotency_key=uuid4(), request_value={"case": "commit"} ) @@ -3803,6 +3961,7 @@ async def test_prepared_postgresql_failure_and_cancellation_are_atomic( == 0 ) await session.rollback() + await assert_handle_burned_without_reentry(participant_handle, participant_input) evidence_input = PreparedAuthorizationInput( idempotency_key=uuid4(), request_value={"case": "evidence"} @@ -3827,13 +3986,7 @@ async def add_authority_event(self, _event): ) await session.rollback() authorization._audit = original_audit - with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - evidence_handle, - ActionId.ACTOR_PROFILE_UPDATE_SELF, - evidence_input, - resource, - ) + await assert_handle_burned_without_reentry(evidence_handle, evidence_input) cancellation_input = PreparedAuthorizationInput( idempotency_key=uuid4(), request_value={"case": "cancellation"} @@ -3861,13 +4014,10 @@ async def cancelled_command(): await cancellation_task assert await session.scalar(text("select 1")) == 1 await session.rollback() - with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - issued_holder["handle"], - ActionId.ACTOR_PROFILE_UPDATE_SELF, - cancellation_input, - resource, - ) + await assert_handle_burned_without_reentry( + issued_holder["handle"], + cancellation_input, + ) commit_failure_input = PreparedAuthorizationInput( idempotency_key=uuid4(), request_value={"case": "commit_failure"} @@ -3905,6 +4055,10 @@ async def fail_commit(): == 0 ) await session.rollback() + await assert_handle_burned_without_reentry( + commit_failure_handle, + commit_failure_input, + ) timeout_input = PreparedAuthorizationInput( idempotency_key=uuid4(), request_value={"case": "timeout"} @@ -3936,6 +4090,7 @@ async def fail_commit(): == 0 ) await session.rollback() + await assert_handle_burned_without_reentry(timeout_handle, timeout_input) async def cancel_after_consume(phase: str): phase_input = PreparedAuthorizationInput( @@ -3997,13 +4152,10 @@ async def command(): == 0 ) await session.rollback() - with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - phase_holder["handle"], - ActionId.ACTOR_PROFILE_UPDATE_SELF, - phase_input, - resource, - ) + await assert_handle_burned_without_reentry( + phase_holder["handle"], + phase_input, + ) await cancel_after_consume("participant_cancel") @@ -4054,13 +4206,10 @@ async def cancel_during_evidence(): == 0 ) await session.rollback() - with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - evidence_holder["handle"], - ActionId.ACTOR_PROFILE_UPDATE_SELF, - evidence_cancel_input, - resource, - ) + await assert_handle_burned_without_reentry( + evidence_holder["handle"], + evidence_cancel_input, + ) commit_cancel_input = PreparedAuthorizationInput( idempotency_key=uuid4(), request_value={"case": "commit_cancel"} @@ -4114,13 +4263,10 @@ async def cancel_during_commit(): == 0 ) await session.rollback() - with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - commit_holder["handle"], - ActionId.ACTOR_PROFILE_UPDATE_SELF, - commit_cancel_input, - resource, - ) + await assert_handle_burned_without_reentry( + commit_holder["handle"], + commit_cancel_input, + ) async with authorization_factory() as locker: await locker.begin() diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index 2178665b1..e83dcccac 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -355,7 +355,9 @@ Product modules receive only these narrow asynchronous capabilities: ```text GuideArtifactIngestPort.ingest(GuideArtifactIngestRequest) SubmissionBundlePreparationPort.prepare(SubmissionBundlePreparationRequest) -ArtifactBindingPort.bind_verified(ArtifactBindingCreateRequest) +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) @@ -364,27 +366,42 @@ get_verification_job/get_recovery_attempt/list_audit_events/admission_usage(...) ArtifactOperatorRecoveryPort.retry_verification(ArtifactRecoveryRequest) ``` -`GuideArtifactIngestRequest` contains the authenticated actor context, project -and guide-source snapshot item IDs, logical role, authorized byte source, and -optional client commitment. `SubmissionBundlePreparationRequest` contains the -authorized contributor/task/assignment context and one outer ZIP byte source. +Every durable mutation request in this port family except the unchanged +Operator recovery request is a process-local, +non-Pydantic value carrying one opaque `PreparedAuthorizationHandle`. The +typed method fixes the expected action; requests contain no caller-selected +ActionId, generic resource context, or facts map. Handles never enter route +schemas, outbox/Celery payloads, provider interfaces, or serialized contracts. + +`GuideArtifactIngestRequest` contains prepared authority, project and +guide-source snapshot item IDs, logical role, authorized byte source, and +optional client commitment. `SubmissionBundlePreparationRequest` contains +prepared authority, contributor task/assignment selectors, and one outer ZIP +byte source. There is no upload-session compatibility port. `PreparedBundleMaterializationRequest` is internal and process-local; it wraps -only the current `PreparedArtifact` generation and locked policy/checker context. +only prepared authority, the exact task/assignment context, the current +`PreparedArtifact` generation, and exact policy/checker selectors. `BindingMaterializationRequest` contains task/submission/checker-run context and immutable binding IDs. `CheckerOutputArtifactRequest` contains the fixed -service actor, task/submission/checker-run IDs, logical role, and generated byte -source. `ArtifactBindingCreateRequest` contains the verified content IDs and -canonical product resource facts that own the binding. +service's prepared authority, task/submission/checker-run IDs, logical role, +and generated byte source. The three action-specific binding requests contain +prepared authority, verified content IDs, and the exact guide setup generation, +Submission, or CheckerRun selectors for their owner. Their typed methods fix +guide, submission, or checker-output authority independently; no request +selects the action. `ArtifactRecoveryRequest` contains the authenticated Operator context, exact source verification-job ID, reason, client idempotency key, expected source-job -CAS version, and canonical authorization resource facts. The read port's -admission-usage request contains only bounded scope filters and pagination. It +CAS version, and canonical authorization resource facts. Reason-bound Operator +recovery remains on its existing bounded authorization +path and is intentionally outside the prepared durable-mutation interface cut. +The read port's admission-usage request contains only bounded scope filters and pagination. It returns current reserved/completed byte usage and configured limits; it cannot release charges or mutate capacity. The binding lookup resource vocabulary is exactly `project`, `project_guide`, -`guide_source_snapshot`, `guide_source_snapshot_item`, `task`, `submission`, -`checker_run`, or `review`. The audit resource vocabulary is exactly +`guide_source_snapshot`, `guide_source_snapshot_item`, `task`, `submission`, or +`checker_run`. Review lookup remains deferred until WS-XINT-002-07. The audit +resource vocabulary is exactly `artifact_binding`, `artifact_content`, `artifact_replica`, `artifact_receipt`, `artifact_verification_job`, or `artifact_recovery_attempt`. Adding a product or artifact resource requires an explicit contract change; callers cannot submit diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 945594838..99d8a37da 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -607,6 +607,15 @@ An exact-consume denial stages its decision only in the caller transaction; the required rollback removes that event with participant state. PREP never restages or commits denial evidence separately. +WS-XINT-002-02 closes the process-local PREP-to-ART operation interface without +activating an action. Durable ART mutation requests carry the opaque +`PreparedAuthorizationHandle`, never a raw `AuthorizationContext`; each typed +method fixes its expected action and accepts no caller-selected action or +generic facts map. The handle is non-Pydantic and cannot enter route schemas, +outbox/Celery payloads, provider interfaces, or serialized contracts. Exact +feature contexts and session/root-bound composer proofs remain owned by the +later evidence-backed activation chunks. + PREP intentionally ships no feature consumer. Its PostgreSQL participant is a test-only neutral row proving that final facts, one decision event, participant work, and caller commit or rollback share the same transaction. Fixed services