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 a4be08bc4..2a3185ea7 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 @@ -6,7 +6,7 @@ waves are superseded prospectively by trusted pre-reconciliation entry evidence; at its merge, WS-XINT-002-01 replaced them with the then-live 71/78/22/56 catalogue recorded in the ART custody section without changing runtime availability. Subsequent AUTH chunks -have advanced the current catalogue to 71/96/37/59. +have advanced the current catalogue to 71/96/43/53. The pre-reconciliation baseline is trusted `main` commit `2fb322bd2249a5fe9d3fa706dc63f033074e38ce`: 76 PermissionIds, 81 ActionIds, 22 active actions, and 59 planned actions. Older counts below are explicitly @@ -38,18 +38,18 @@ mappings, and availability must remain identical. ## ART custody transfer -| AUTH activation chunk | Exact planned ActionIds | +| AUTH activation chunk | Exact ActionIds and current availability | |---|---| -| `WS-AUTH-001-ART-02D-INTERNAL` | `artifact.verification.execute`, `artifact.pending_work.scan`, `artifact.put_attempt.resolve` | -| `WS-AUTH-001-ART-02D-OPERATOR` | `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | -| `WS-AUTH-001-ART-03` | `artifact.guide_source.read`, `artifact.guide_source.binding.create` | -| `WS-XINT-002-04A` | `artifact.guide_source.ingest` | -| `WS-XINT-002-05A` | `artifact.submission_bundle.prepare` | -| `WS-AUTH-001-ART-04B` | `artifact.pre_submit.checker_input.materialize` | -| `WS-AUTH-001-ART-05` | `artifact.submission.binding.create` | -| `WS-AUTH-001-ART-06A` | `artifact.post_submit.checker_input.materialize` | -| `WS-AUTH-001-ART-06B` | `artifact.checker_output.write`, `artifact.checker_output.binding.create` | -| `WS-XINT-002-07` | `artifact.review_packet.materialize`, `artifact.review_evidence.binding.create` | +| `WS-AUTH-001-ART-02D-INTERNAL` | Active: `artifact.verification.execute`, `artifact.pending_work.scan`, `artifact.put_attempt.resolve` | +| `WS-AUTH-001-ART-02D-OPERATOR` | Planned: `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | +| `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-AUTH-001-ART-04B` | Planned: `artifact.pre_submit.checker_input.materialize` | +| `WS-AUTH-001-ART-05` | Planned: `artifact.submission.binding.create` | +| `WS-AUTH-001-ART-06A` | Planned: `artifact.post_submit.checker_input.materialize` | +| `WS-AUTH-001-ART-06B` | Planned: `artifact.checker_output.write`, `artifact.checker_output.binding.create` | +| `WS-XINT-002-07` | Planned: `artifact.review_packet.materialize`, `artifact.review_evidence.binding.create` | Runtime owner `WS-XINT-002-07` contains two planning sub-waves: 07A is the only availability transition and initially permits finding slots; 07B changes @@ -60,14 +60,15 @@ reconciles the live catalogue by removing the six unused multi-step upload rows and registering three end-to-end bundle/review rows. The resulting 22 rows have exact owner cardinalities `3/8/2/1/1/1/1/1/2/2` in the table order above. The `OPERATOR` suffix denotes only future activation custody; it grants no Operator -entitlement. Eighteen actions remain planned after the three fixed-service ART -actions and `artifact.guide_source.ingest` activate. The independently +entitlement. Sixteen actions remain planned after the three ART foundation +service actions, `artifact.guide_source.ingest`, and the two fixed-service +guide binding/read actions activate. The independently gated `artifact.verification_job.retry` remains planned and cannot be activated by read/status proof. The historical transfer added no migration because owner and availability are typed metadata. WS-XINT-002-01 reconciles PostgreSQL parity through migration `0036`; the live catalogue has -71 PermissionIds, 96 ActionIds, 37 active actions, and 59 planned actions, with +71 PermissionIds, 96 ActionIds, 43 active actions, and 53 planned actions, with eight fixed-service identities and sixteen matrix memberships. ## REV custody transfer diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-external-review-response.md index a9fe1faa1..dd33db5f8 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-external-review-response.md @@ -1,32 +1,68 @@ -# External Review Response: WS-XINT-002-04B Planning Amendment +# External Review Response: WS-XINT-002-04B -## Comments addressed +## Runtime comments addressed -- CodeRabbit correctly identified that the verification command's `` - placeholder is parsed by the shell as redirection. The command now requires - and reuses an existing `WORKSTREAM_TEST_DATABASE_URL` value through an - executable shell expansion. -- Agent Gates correctly rejected two unqualified background-executor references. They now use - the exact technical terms `Celery task payload` and `Celery task/route - composition`, preserving the separation from Workstream's human contributor - vocabulary. +- The custody table now labels every action row as Active or Planned. +- Review evidence now states that local review is provisional until hosted + database-backed full coverage passes on the exact PR head. +- Guide materialization preserves its bounded public error when best-effort + incident persistence fails, and format-inspection deadline failures use the + same bounded incident path. +- Concurrent guide reads no longer take an exclusive lock on the immutable + singleton storage-namespace row. Exact mutable lineage remains locked through + provider access and the protected write. +- The lock-contention test now proves PostgreSQL SQLSTATE `55P03` directly + instead of accepting any `DBAPIError` as evidence of a lock. +- Fixed-service context construction now verifies that the loaded profile has + the exact requested service identity. +- Guide PREP scope composition is action-gated through one module-level map. +- Inert test parameters and the unused materialization-helper authority were + removed. The dataclass binding request is updated with `dataclasses.replace`. +- The hosted active-action audit expectation now includes both 04B actions. -## Comments deferred +## Runtime comment rejected as stale -None. +- The claimed authority-fact mismatch does not exist on this branch. + `GuideSourceBindingAuthorityFacts` already contains `logical_role`, and + `GuideSourceReadAuthorityFacts` already contains `binding_id`; focused tests + construct and consume both strict resource contexts. -## Human decisions needed +## Runtime comment deferred -Human review and merge of the corrected 04B security boundary remain required. +- A new database `lock_timeout`/`statement_timeout` and lock-duration metric are + not added in 04B. The provider operation already runs under the bounded + `ArtifactPreparationService` deadline. PostgreSQL `statement_timeout` does + not bound time spent awaiting provider I/O after the locking statement has + completed, while an observability surface is outside this activation chunk. + ART worker operational tuning can add a transaction-idle bound and metric in + a dedicated, evidence-backed chunk without weakening the required lineage + lock. -## Commands rerun +## Earlier planning comments addressed -- `python3 scripts/check_stale_authorization_docs.py` -- `python3 scripts/check_stale_artifact_contracts.py` -- `python3 scripts/check_markdown_links.py` -- `git diff --check` +- The verification command no longer uses a shell-redirection-shaped + `` placeholder; it consumes `WORKSTREAM_TEST_DATABASE_URL`. +- Background executor wording uses the exact terms `Celery task payload` and + `Celery task/route composition`. -## Remaining risks +## Verification -No runtime action is activated by this planning amendment. Exact-head Agent -Gates, Backend, and CodeRabbit must pass before merge. +- Reviewed implementation commits + `8c48c01e137f861210bccfbc6bfaa91f13b0a354` and `8b468881`, with exact local + commands, are recorded in the internal review. The subsequent commit changes + review evidence only. +- Local Ruff, focused AUTH/audit/architecture tests, stale authorization docs, + stale artifact contracts, Markdown links, and `git diff --check` pass. +- Database-backed guide tests and repository-wide coverage remain assigned to + hosted `Backend / test` because this shell has no + `WORKSTREAM_TEST_DATABASE_URL`. +- Hosted run `30749925248` passed all 2,841 semantic nodes and failed only the + unchanged 90% per-file kernel gate at 89.93%. Commit `8b468881` consolidates + equivalent terminal denial branches, restoring the prior kernel statement + count without exclusions, ignored lines, or threshold changes. Its successor + exact-head run remains required. + +## Remaining risk + +All seven CodeRabbit threads are resolved. PR #245 remains non-merge-ready until +hosted exact-head `Backend / test` plus `Agent Gates / agent-gates` pass. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-internal-review.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-internal-review.md new file mode 100644 index 000000000..8ee8c888f --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-internal-review.md @@ -0,0 +1,92 @@ +# Internal Review: WS-XINT-002-04B + +## Result + +Provisional local PASS after repair. Merge readiness remains pending hosted +full coverage and database-backed guide tests on the exact PR head. + +## Blocking findings resolved + +- Security and QA found that the first read adapter committed PREP evidence and + released lineage locks before provider access. The materializer now locks the + exact guide, snapshot, item, setup run/generation, binding, content, replica, + namespace, verification job, and receipt through PREP consumption, provider + materialization, classification, and the classification write in one root + transaction. +- QA found missing exact resource-digest evidence. Both new resource types now + persist `resource_context_digest` in bounded authorization audit facts. +- Test-delta review found missing human/Admin and binding-input negatives. Tests + now prove humans cannot substitute for either fixed service and wrong content + or logical role creates no binding or authority consumption. +- Product/docs review found stale catalogue and ART availability counts. The + custody ledger, authorization spec, artifact spec, and operations runbook now + agree on 71 permissions, 96 actions, 43 active, 53 planned, and 16 remaining + planned ART actions. +- Senior/reuse review found cleanup and duplicated fixed-service lifecycle + plumbing. Prepare failures now always close capabilities, and both guide and + foundation ART adapters share one service-context loader and revalidator. + +## Final reviewer results + +- Security/auth: pass with low documentation risk, corrected. +- Architecture: pass with low risk. +- QA/test: pass with low operational risk. +- Senior engineering: pass with low operational risk. +- Product/ops: pass. +- CI integrity: pass with low hosted-test dependency. +- Docs: pass with low wording risk, corrected. +- Reuse/dedup: pass with low registry-map drift risk. +- Test delta: pass with low risk. + +## Verification evidence + +Reviewed implementation commits: +`8c48c01e137f861210bccfbc6bfaa91f13b0a354` for the CodeRabbit correction and +`8b468881` for the behavior-preserving kernel coverage repair. The following +commit changes review evidence only; hosted checks must pass on that final +evidence head too. + +- `cd backend && .venv/bin/ruff check app tests scripts`: passed. +- `cd backend && .venv/bin/pytest -q tests/test_audit.py + tests/test_authorization.py -k 'action_aware_audit_input or guide_service or + fixed_service_context or human_authority_cannot'`: 8 passed, 408 deselected. +- `cd backend && .venv/bin/pytest -q tests/test_artifact_architecture.py`: 20 + passed. +- `python3 scripts/check_stale_authorization_docs.py`: passed. +- `python3 scripts/check_stale_artifact_contracts.py`: passed. +- `python3 scripts/check_markdown_links.py`: passed. +- `git diff --check`: passed. +- Database-backed guide binding/materialization and full coverage remain assigned + to hosted `Backend / test` because this local venv lacks Pillow and the local + shell has no `WORKSTREAM_TEST_DATABASE_URL`. + +## Corrective reviewer reruns + +- Security/auth: pass with low risk; the binding handle remains intentionally + process-local and caller-transaction-bound, while reading obtains fresh + authority inside the materializer transaction. +- QA: pass with low risk; all CodeRabbit code findings are addressed and all + review threads are resolved. +- Product/ops: pass after the PREP support and denial-restage documentation was + corrected. +- Docs: runtime wording passes; this evidence now names the reviewed + implementation head. +- Senior engineering: pass with low operational lock-duration risk deferred to + ART worker tuning. +- Reuse/dedup: pass after consolidating the two guide action maps. +- Test delta: pass after adding scratch-cleanup and unchanged-incident-count + assertions to the incident-write failure case. +- Security and QA re-reviewed `8b468881`: its consolidated terminal denial + preserves `permission_not_granted` for known ART-internal actions and + `action_unavailable` for other unsupported actions, without creating an allow + path. Ruff and 15 focused behavior tests pass. + +## Readiness dependency + +Planning/scope PR #244 is merged and the runtime branch is rebased onto current +`main`. Hosted exact-head checks remain required before merge readiness. +Run `30749925248` proved all 2,841 semantic nodes but found +`authorization/kernel.py` at 89.93% against the unchanged 90% per-file gate. +The `8b468881` repair restores the kernel to its prior 584 executable statements +without exclusions or threshold changes; a fresh exact-head hosted run is +required. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-pr-trust-bundle.md new file mode 100644 index 000000000..bfe38eb73 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04B-pr-trust-bundle.md @@ -0,0 +1,105 @@ +# PR Trust Bundle: WS-XINT-002-04B + +## Chunk + +`WS-XINT-002-04B` — guide binding and guide read authorization activation. + +## Goal and human-approved intent + +Activate exactly `artifact.guide_source.binding.create` for +`workstream.artifact.binding` and `artifact.guide_source.read` for +`workstream.artifact.guide_reader`, preserving exact transaction, identity, +lineage, verified-content, and no-provider-I/O-on-denial guarantees. + +## What changed and why + +- Added closed typed binding/read resource contexts to the existing PREP kernel. +- Reconciled the two catalogue rows to active `WS-XINT-002-04B` custody. +- Added production fixed-service adapters using the existing opaque single-use + `PreparedAuthorizationHandle` protocol. +- Removed the impossible caller-supplied read handle. The materializer obtains + fresh authority in its owned session and holds exact lineage locks through the + protected provider read and atomic classification write. +- Added exact digest evidence and fixed runtime/docs/custody parity. + +## Design chosen + +Reuse centralized PREP with two closed contexts and fixed service identities. +Binding retains the caller-owned transaction. Reading prepares and consumes +inside the materializer-owned transaction because handles cannot cross sessions. +The protected read holds canonical lineage locks through provider access. + +## Alternatives rejected + +- Serializable or reconstructable handles: violates opaque process-local PREP. +- Preparing the read in an earlier worker/session: violates transaction binding. +- Committing authorization before provider access and revalidating afterward: + leaves a stale-lineage race. +- Generic download or role-derived service authority: violates least privilege. + +## Scope control and product behavior + +No new action/permission identifiers, migration, route, Celery payload, +submission/checker/review authority, generic download, parser behavior, or +ART-03C legacy cutover. Project Managers retain ingest only; neither human nor +Admin authority implies binding/read service authority. + +## Acceptance proof and test delta + +- Exact typed facts, action/service matrix, session/transaction, single-use, + copied/wrong handle, replay, wrong service, human substitution, every adapter + fact mismatch, cross-resource selectors, stale generation, wrong content, and + wrong logical role are covered. +- Denial tests assert no provider read, binding, classification, or allowed + evidence where applicable. +- Architecture tests prove materialization requests carry identifiers and an + idempotency key, never a prepared handle. +- No tests were skipped, deleted, or weakened. The prior post-read stale-incident + expectation was replaced by the stronger lock-through-provider invariant. + +## Tests/checks run + +- `ruff check app tests scripts`: passed. +- `pytest tests/test_artifact_architecture.py -q`: 20 passed. +- Focused `tests/test_authorization.py` guide/custody/service cases: passed. +- Stale AUTH docs, stale ART contracts, Markdown links, and diff check: passed. +- Hosted full Backend coverage and database-backed guide tests: required on the + exact PR head. +- Hosted run `30749925248` passed all 2,841 semantic nodes, then found the kernel + at 89.93% against the unchanged 90% per-file gate. The narrow follow-up + preserves denial behavior while restoring the pre-chunk executable-statement + count; exact-head hosted proof is pending. + +## CI integrity + +No workflow, dependency, package script, test config, skip/xfail, coverage +threshold, or fail-open changes. + +## Reviewer results + +Security, architecture, QA, senior engineering, product/ops, CI integrity, +docs, reuse/dedup, and test-delta tracks pass after all blocking findings were +resolved. Details are in `WS-XINT-002-04B-internal-review.md`. + +## External review + +PR #244 is merged and PR #245 now targets `main`. Every valid CodeRabbit finding +was fixed, the one stale fact-model finding was rejected with code evidence, and +all seven review threads are resolved. A fresh CodeRabbit invocation was +requested but rate-limited, so the existing review and recorded dispositions +remain the external review evidence. Hosted exact-head Backend coverage remains +required before readiness. + +## Remaining risks and follow-up work + +- Holding lineage locks through bounded provider I/O is intentionally strict and + operationally heavier; ART-03C worker tuning must preserve deadlines. +- If more ART internal resource contexts are added, consolidate the small + prepared/kernel mapping registries. +- ART-03C later owns live worker/route composition and legacy-path removal. + +## Human review focus and merge ownership + +Review exact fixed identities, full fact manifests, lock-through-read ordering, +atomic decision evidence, no human inheritance, and the absence of ART-03C scope. +Human approval owns every merge. diff --git a/backend/app/interfaces/artifact_operations.py b/backend/app/interfaces/artifact_operations.py index ae45a7769..4a309a0b8 100644 --- a/backend/app/interfaces/artifact_operations.py +++ b/backend/app/interfaces/artifact_operations.py @@ -197,7 +197,7 @@ class GuideSourceBindingResult: class GuideSourceMaterializationRequest: """Exact guide binding selected for one authorized verified read.""" - prepared_authorization: PreparedAuthorizationHandle + idempotency_key: UUID project_id: UUID guide_id: UUID guide_source_snapshot_id: UUID diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index a6f54e506..ad35fa883 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import asdict from typing import Annotated, Protocol from uuid import UUID @@ -20,6 +21,8 @@ from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, GuideArtifactIngestAuthorityFacts, + GuideSourceBindingAuthorityFacts, + GuideSourceReadAuthorityFacts, ArtifactInternalAuthorityFacts, ArtifactInternalResourceType, ArtifactPendingWorkAuthorityFacts, @@ -46,6 +49,8 @@ AuthorizationDecision, AuthorizationDenied, GuideSourceIngestResourceContext, + GuideSourceBindingResourceContext, + GuideSourceReadResourceContext, HumanAuthorizationContext, PreparedAuthorizationHandleInvalid, PreparedAuthorizationUnsupported, @@ -349,6 +354,239 @@ def get_guide_artifact_prepared_authorization( return PreparedGuideArtifactAuthorization(session) +class _PreparedGuideSourceServiceAuthorization: + """Issue and consume one exact fixed-service guide capability.""" + + def __init__( + self, + session: AsyncSession, + *, + service_identity: ServiceIdentity, + action_id: ActionId, + request_id: UUID, + correlation_id: UUID, + ) -> None: + self._session = session + self._service_identity = service_identity + self._action_id = action_id + self._request_id = request_id + self._correlation_id = correlation_id + self._prepared: PreparedAuthorizationService | None = None + self._input: PreparedAuthorizationInput | None = None + self._handle: PreparedAuthorizationHandle | None = None + self._facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts | None = None + + async def prepare( + self, + *, + facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts, + 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) + context = await _fixed_service_context( + self._session, + self._service_identity, + self._request_id, + self._correlation_id, + ) + repository = AdminAuthorizationRepository(self._session) + + authorization = AuthorizationService( + self._session, + context, + revalidate_service=_fixed_service_revalidator( + repository, self._service_identity + ), + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + self._session, context, authorization, repository + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=resource.model_dump(mode="json"), + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type=resource.resource_type, + artifact_resource_id=resource.resource_id, + ) + try: + handle = await prepared.prepare(self._action_id, caller_input, scope) + except ( + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ValidationError, + ) as exc: + prepared.close() + raise ArtifactAuthorityDeniedError("guide source authority is unavailable") from exc + except BaseException: + prepared.close() + raise + self._prepared = prepared + self._input = caller_input + self._handle = handle + self._facts = facts + return handle + + async def consume( + self, + *, + prepared_authorization: PreparedAuthorizationHandle, + facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts, + ) -> 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 + ): + raise ArtifactAuthorityDeniedError("guide source authority is invalid") + prepared = self._prepared + try: + await prepared.consume( + prepared_authorization, + self._action_id, + self._input, + _guide_source_resource_context(facts), + ) + except (AuthorizationDenied, PreparedAuthorizationHandleInvalid, ValidationError) as exc: + raise ArtifactAuthorityDeniedError("guide source authority is unavailable") from exc + finally: + prepared.close() + self._prepared = None + self._input = None + self._handle = None + self._facts = None + + def close(self) -> None: + """Invalidate an unconsumed capability.""" + if self._prepared is not None: + self._prepared.close() + self._prepared = None + self._input = None + self._handle = None + self._facts = None + + +class PreparedGuideSourceBindingAuthorization(_PreparedGuideSourceServiceAuthorization): + """Prepared authority reserved to the fixed guide binding service.""" + + def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: UUID) -> None: + super().__init__( + session, + service_identity=ServiceIdentity.ARTIFACT_BINDING, + action_id=ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + request_id=request_id, + correlation_id=correlation_id, + ) + + +class PreparedGuideSourceReadAuthorization(_PreparedGuideSourceServiceAuthorization): + """Prepared authority reserved to the fixed guide reader service.""" + + def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: UUID) -> None: + super().__init__( + session, + service_identity=ServiceIdentity.ARTIFACT_GUIDE_READER, + action_id=ActionId.ARTIFACT_GUIDE_SOURCE_READ, + request_id=request_id, + correlation_id=correlation_id, + ) + + +def _guide_source_resource_context( + facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts, +) -> GuideSourceBindingResourceContext | GuideSourceReadResourceContext: + values = asdict(facts) + if isinstance(facts, GuideSourceBindingAuthorityFacts): + return GuideSourceBindingResourceContext( + resource_type="guide_source_binding", + resource_id=facts.guide_source_item_id, + **values, + ) + return GuideSourceReadResourceContext( + resource_type="guide_source_read", + resource_id=facts.binding_id, + **values, + ) + + +async def _fixed_service_context( + session: AsyncSession, + service_identity: ServiceIdentity, + request_id: UUID, + correlation_id: UUID, +) -> ServiceAuthorizationContext: + actors = ActorRepository(session) + profile = await actors.get_service_actor(service_identity.value) + if profile is None or profile.service_identity != service_identity.value: + raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") + link = await actors.get_identity_link_for_actor(profile.id) + if ( + link is None + or link.actor_profile_id != profile.id + or link.subject_kind != ActorKind.SERVICE.value + ): + raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") + try: + return ServiceAuthorizationContext( + actor_profile_id=UUID(profile.id), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus(profile.status), + identity_link_id=UUID(link.id), + identity_link_status=IdentityLinkStatus(link.status), + service_identity=service_identity, + request_id=request_id, + correlation_id=correlation_id, + ) + except (TypeError, ValueError) as exc: + raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") from exc + + +def _fixed_service_revalidator( + repository: AdminAuthorizationRepository, + expected_identity: ServiceIdentity, +): + """Build the single lifecycle revalidator shared by fixed ART adapters.""" + + async def revalidate( + original: ServiceAuthorizationContext, + _requested_action: ActionId, + ) -> ServiceAuthorizationContext | None: + locked = await repository.lock_request_actor( + original.identity_link_id, original.actor_profile_id + ) + if locked is None: + return None + link, profile = locked + if ( + profile.actor_kind != ActorKind.SERVICE.value + or profile.service_identity != expected_identity.value + ): + return None + try: + return ServiceAuthorizationContext( + actor_profile_id=UUID(profile.id), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus(profile.status), + identity_link_id=UUID(link.id), + identity_link_status=IdentityLinkStatus(link.status), + service_identity=expected_identity, + request_id=original.request_id, + correlation_id=original.correlation_id, + ) + except (TypeError, ValueError): + return None + + return revalidate + + class PreparedArtifactInternalAuthority: """Adapt one fixed ART service to the shared transaction-bound PREP kernel.""" @@ -393,36 +631,12 @@ async def prepare( context = await self._service_context() repository = AdminAuthorizationRepository(self._session) - async def revalidate_service( - original: ServiceAuthorizationContext, - _requested_action: ActionId, - ) -> ServiceAuthorizationContext | None: - locked = await repository.lock_request_actor( - original.identity_link_id, original.actor_profile_id - ) - if locked is None: - return None - link, profile = locked - if ( - profile.actor_kind != ActorKind.SERVICE.value - or profile.service_identity != original.service_identity.value - ): - return None - return ServiceAuthorizationContext( - actor_profile_id=UUID(profile.id), - actor_kind=ActorKind.SERVICE, - actor_status=ActorStatus(profile.status), - identity_link_id=UUID(link.id), - identity_link_status=IdentityLinkStatus(link.status), - service_identity=original.service_identity, - request_id=original.request_id, - correlation_id=original.correlation_id, - ) - authorization = AuthorizationService( self._session, context, - revalidate_service=revalidate_service, + revalidate_service=_fixed_service_revalidator( + repository, self._service_identity + ), admin_repository=repository, ) prepared = PreparedAuthorizationService(self._session, context, authorization, repository) @@ -513,30 +727,12 @@ async def persist_denial(self) -> None: await self._session.commit() async def _service_context(self) -> ServiceAuthorizationContext: - actors = ActorRepository(self._session) - profile = await actors.get_service_actor(self._service_identity.value) - if profile is None: - raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") - link = await actors.get_identity_link_for_actor(profile.id) - if ( - link is None - or link.actor_profile_id != profile.id - or link.subject_kind != ActorKind.SERVICE.value - ): - raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") - try: - return ServiceAuthorizationContext( - actor_profile_id=UUID(profile.id), - actor_kind=ActorKind.SERVICE, - actor_status=ActorStatus(profile.status), - identity_link_id=UUID(link.id), - identity_link_status=IdentityLinkStatus(link.status), - service_identity=ServiceIdentity(profile.service_identity), - request_id=self._request_id, - correlation_id=self._correlation_id, - ) - except (TypeError, ValueError) as exc: - raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") from exc + return await _fixed_service_context( + self._session, + self._service_identity, + self._request_id, + self._correlation_id, + ) def _scope( diff --git a/backend/app/modules/artifacts/guide_materialization.py b/backend/app/modules/artifacts/guide_materialization.py index a810d7faa..1db3133aa 100644 --- a/backend/app/modules/artifacts/guide_materialization.py +++ b/backend/app/modules/artifacts/guide_materialization.py @@ -3,10 +3,12 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Protocol from uuid import UUID, uuid4 from sqlalchemy import func, select +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.interfaces.artifact_operations import ( @@ -57,13 +59,39 @@ ) +logger = logging.getLogger(__name__) + + class GuideSourceMaterializationError(RuntimeError): """Concealed guide materialization failure.""" +class _GuideReadIncident(RuntimeError): + """Internal signal used to persist bounded incidents after lock rollback.""" + + def __init__( + self, + code: str, + *, + observed_sha256: str | None = None, + observed_byte_count: int | None = None, + ) -> None: + self.code = code + self.observed_sha256 = observed_sha256 + self.observed_byte_count = observed_byte_count + super().__init__(code) + + class GuideSourceReadPreparedAuthorization(Protocol): """AUTH-04B seam for one transaction-bound fixed-reader capability.""" + async def prepare( + self, + *, + facts: GuideSourceReadAuthorityFacts, + idempotency_key: UUID, + ) -> PreparedAuthorizationHandle: ... + async def consume( self, *, @@ -81,6 +109,15 @@ def __call__(self, session: AsyncSession) -> GuideSourceReadPreparedAuthorizatio class DenyGuideSourceReadPreparedAuthorization: """Production default until AUTH-04B activates exact guide reads.""" + async def prepare( + self, + *, + facts: GuideSourceReadAuthorityFacts, + idempotency_key: UUID, + ) -> PreparedAuthorizationHandle: + del facts, idempotency_key + raise ArtifactAuthorityDeniedError("guide source read is unavailable") + async def consume( self, *, @@ -142,65 +179,54 @@ async def materialize_guide_source( request: GuideSourceMaterializationRequest, ) -> GuideSourceMaterializationResult: """Authorize, read, verify, classify, revalidate, and persist one result.""" - if ( - type(request.prepared_authorization) is not PreparedAuthorizationHandle - or request.setup_generation <= 0 - ): + if request.setup_generation <= 0: raise GuideSourceMaterializationError("guide source read is unavailable") - async with self._session_factory() as session, session.begin(): - before = await self._load_read_facts(session, request) - if before is None: - raise GuideSourceMaterializationError("guide source read is unavailable") - await self._authority_factory(session).consume( - prepared_authorization=request.prepared_authorization, - facts=self._authority_facts(before), - ) - + before: _ReadFacts | None = None prepared = None try: - prepared = await self._preparation.prepare( - self._store.open(before.provider_object_ref), - media_type=before.media_type, - ) - commitment = prepared.commitment - if commitment.sha256 != before.sha256 or commitment.byte_count != before.byte_count: - code = "truncated" if commitment.byte_count < before.byte_count else "changed" - await self._record_incident( - before, - code, - observed_sha256=commitment.sha256, - observed_byte_count=commitment.byte_count, + async with self._session_factory() as session, session.begin(): + before = await self._load_read_facts(session, request) + if before is None: + raise GuideSourceMaterializationError("guide source read is unavailable") + facts = self._authority_facts(before) + authority = self._authority_factory(session) + prepared_authorization = await authority.prepare( + facts=facts, + idempotency_key=request.idempotency_key, ) - raise GuideSourceMaterializationError("guide artifact incident") - detected = await prepared.inspect( - BoundGuideFormatInspector( - detector=self._detector, - declared_media_type=before.declared_media_type, - ingestion_adapter=before.ingestion_adapter, + await authority.consume( + prepared_authorization=prepared_authorization, + facts=facts, ) - ) - except ArtifactObjectMissingError: - await self._record_incident(before, "missing") - raise GuideSourceMaterializationError("guide artifact incident") from None - except ArtifactInputMismatchError: - # Kept fail-closed for alternate preparation implementations. - await self._record_incident(before, "changed") - raise GuideSourceMaterializationError("guide artifact incident") from None - except (ArtifactStoreUnavailableError, ArtifactPreparationDeadlineError): - await self._record_incident(before, "unavailable") - raise GuideSourceMaterializationError("guide artifact incident") from None - finally: - if prepared is not None: - await prepared.close() - - stale = False - result: GuideSourceMaterializationResult | None = None - async with self._session_factory() as session, session.begin(): - after = await self._load_read_facts(session, request) - if after != before: - await self._add_incident(session, before, "stale") - stale = True - else: + try: + prepared = await self._preparation.prepare( + self._store.open(before.provider_object_ref), + media_type=before.media_type, + ) + except ArtifactObjectMissingError as exc: + raise _GuideReadIncident("missing") from exc + except ArtifactInputMismatchError as exc: + raise _GuideReadIncident("changed") from exc + except (ArtifactStoreUnavailableError, ArtifactPreparationDeadlineError) as exc: + raise _GuideReadIncident("unavailable") from exc + commitment = prepared.commitment + if commitment.sha256 != before.sha256 or commitment.byte_count != before.byte_count: + code = "truncated" if commitment.byte_count < before.byte_count else "changed" + raise _GuideReadIncident( + code, + observed_sha256=commitment.sha256, + observed_byte_count=commitment.byte_count, + ) + try: + detected = await prepared.inspect( + BoundGuideFormatInspector( + detector=self._detector, + declared_media_type=before.declared_media_type, + ingestion_adapter=before.ingestion_adapter, + ) + ) + except ArtifactPreparationDeadlineError as exc: + raise _GuideReadIncident("unavailable") from exc existing = await session.scalar( select(GuideSourceFormatClassification) .where(GuideSourceFormatClassification.binding_id == before.binding_id) @@ -221,31 +247,40 @@ async def materialize_guide_source( or existing.detector_version != DETECTOR_VERSION ): raise GuideSourceMaterializationError("guide classification conflicts") - result = self._result(existing, replayed=True) - else: - classification = GuideSourceFormatClassification( - id=str(uuid4()), - binding_id=before.binding_id, - content_id=before.content_id, - verified_replica_id=before.replica_id, - setup_generation=before.setup_generation, - sha256=before.sha256, - byte_count=before.byte_count, - media_type=before.media_type, - detected_format=detected.detected_format, - status=detected.status, - detector_name=DETECTOR_NAME, - detector_version=DETECTOR_VERSION, - classification_facts=detected.facts, + return self._result(existing, replayed=True) + classification = GuideSourceFormatClassification( + id=str(uuid4()), + binding_id=before.binding_id, + content_id=before.content_id, + verified_replica_id=before.replica_id, + setup_generation=before.setup_generation, + sha256=before.sha256, + byte_count=before.byte_count, + media_type=before.media_type, + detected_format=detected.detected_format, + status=detected.status, + detector_name=DETECTOR_NAME, + detector_version=DETECTOR_VERSION, + classification_facts=detected.facts, + ) + session.add(classification) + await session.flush() + return self._result(classification, replayed=False) + except _GuideReadIncident as incident: + if before is not None: + try: + await self._record_incident( + before, + incident.code, + observed_sha256=incident.observed_sha256, + observed_byte_count=incident.observed_byte_count, ) - session.add(classification) - await session.flush() - result = self._result(classification, replayed=False) - if stale: - raise GuideSourceMaterializationError("guide artifact incident") - if result is None: - raise GuideSourceMaterializationError("guide source read is unavailable") - return result + except SQLAlchemyError: + logger.exception("guide source incident could not be recorded") + raise GuideSourceMaterializationError("guide artifact incident") from None + finally: + if prepared is not None: + await prepared.close() async def _load_read_facts( self, @@ -344,7 +379,19 @@ async def _load_read_facts( ) .order_by(ArtifactVerificationReceipt.created_at.desc()) .limit(1) - .with_for_update(of=(GuideSourceArtifactBinding, ArtifactReplica, ProjectSetupRun)) + .with_for_update( + of=( + ProjectGuide, + GuideSourceSnapshot, + GuideSourceSnapshotItem, + ProjectSetupRun, + GuideSourceArtifactBinding, + ArtifactContent, + ArtifactReplica, + ArtifactVerificationJob, + ArtifactVerificationReceipt, + ) + ) ) ).one_or_none() if row is None: diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 5bf373dd4..c637edb62 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -230,12 +230,12 @@ 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_03 = "WS-AUTH-001-ART-03" 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" XINT_002_04A = "WS-XINT-002-04A" + XINT_002_04B = "WS-XINT-002-04B" XINT_002_05A = "WS-XINT-002-05A" XINT_002_07 = "WS-XINT-002-07" @@ -656,20 +656,20 @@ def _active( PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionOwner.XINT_002_04A, ), - _planned( + _active( ActionId.ARTIFACT_GUIDE_SOURCE_READ, PermissionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionOwner.AUTH_ART_03, + ActionOwner.XINT_002_04B, ), _planned( ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, PermissionId.SUBMISSION_CREATE, ActionOwner.XINT_002_05A, ), - _planned( + _active( ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_03, + ActionOwner.XINT_002_04B, ), _planned( ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, @@ -812,6 +812,8 @@ def _index_actions( ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, ActionId.PROJECT_ACTIVE_GUIDE_READ, ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -915,7 +917,7 @@ def _index_service_actions( ), ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE: ( PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_03, + ActionOwner.XINT_002_04B, ), ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( PermissionId.ARTIFACT_BINDING_CREATE, @@ -927,7 +929,7 @@ def _index_service_actions( ), ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( PermissionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionOwner.AUTH_ART_03, + ActionOwner.XINT_002_04B, ), ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, @@ -979,6 +981,8 @@ def _index_service_actions( ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, } else ActionAvailability.PLANNED ) diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 25ace0845..106a91b65 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -46,6 +46,8 @@ ArtifactPendingWorkResourceContext, ArtifactPutAttemptResourceContext, ArtifactVerificationJobResourceContext, + GuideSourceBindingResourceContext, + GuideSourceReadResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -167,6 +169,14 @@ ) _ARTIFACT_INTERNAL_RESOURCES = { + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE: ( + "guide_source_binding", + GuideSourceBindingResourceContext, + ), + ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( + "guide_source_read", + GuideSourceReadResourceContext, + ), ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( "artifact_put_attempt", ArtifactPutAttemptResourceContext, @@ -537,7 +547,11 @@ async def _prepare_prelocked( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) else: - raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if action_id in _ARTIFACT_INTERNAL_RESOURCES + else AuthorizationDenialCode.ACTION_UNAVAILABLE + ) lifecycle = self._lifecycle_denial(context) if lifecycle is not None or context.actor_kind is not ActorKind.HUMAN: raise PreparedAuthorizationUnsupported( @@ -1426,6 +1440,8 @@ async def _stage_decision( "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", + "guide_source_binding", + "guide_source_read", "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 24e93e4f8..23d7f8383 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -23,6 +23,8 @@ ArtifactPendingWorkResourceContext, ArtifactPutAttemptResourceContext, ArtifactVerificationJobResourceContext, + GuideSourceBindingResourceContext, + GuideSourceReadResourceContext, GuideSourceIngestResourceContext, AuthorizationContext, AuthorizationDecision, @@ -65,6 +67,17 @@ def __reduce__(self) -> NoReturn: _HANDLE_CONSTRUCTOR_TOKEN = object() +_GUIDE_RESOURCE_BY_ACTION = { + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE: ( + "guide_source_binding", + GuideSourceBindingResourceContext, + ), + ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( + "guide_source_read", + GuideSourceReadResourceContext, + ), +} + @dataclass(frozen=True, slots=True) class _PreparedAuthorizationBinding: @@ -352,6 +365,16 @@ 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]) + ): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type=guide_resource[0], + artifact_resource_id=resource.resource_id, + ) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF and isinstance( resource, ActorSelfResourceContext ): diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 18bdd1760..f1c5cf2f4 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -110,6 +110,8 @@ class PreparedAuthorityScope(BaseModel): "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", + "guide_source_binding", + "guide_source_read", ] | None ) = None @@ -164,7 +166,12 @@ def validate_selector(self): ) or ( self.artifact_resource_type - in {"artifact_put_attempt", "artifact_verification_job"} + in { + "artifact_put_attempt", + "artifact_verification_job", + "guide_source_binding", + "guide_source_read", + } and isinstance(self.artifact_resource_id, UUID) ) ) @@ -1214,6 +1221,63 @@ def bind_page_size(self): return self +class GuideSourceBindingResourceContext(BaseModel): + """Exact verified guide-source lineage authorized for one binding write.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["guide_source_binding"] + resource_id: UUID + project_id: UUID + guide_id: UUID + guide_source_snapshot_id: UUID + guide_source_item_id: UUID + project_setup_run_id: UUID + setup_generation: int = Field(gt=0) + content_id: UUID + verified_replica_id: UUID + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(ge=0) + logical_role: Literal["guide_source_original"] + + @model_validator(mode="after") + def bind_source_item(self): + """Use the exact source item as the prepared resource selector.""" + if self.resource_id != self.guide_source_item_id: + raise ValueError("guide binding resource must match source item") + return self + + +class GuideSourceReadResourceContext(BaseModel): + """Exact verified binding and replica facts authorized for one provider read.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["guide_source_read"] + resource_id: UUID + project_id: UUID + guide_id: UUID + guide_source_snapshot_id: UUID + guide_source_item_id: UUID + project_setup_run_id: UUID + setup_generation: int = Field(gt=0) + binding_id: UUID + content_id: UUID + verified_replica_id: UUID + storage_namespace_id: str = Field(min_length=1, max_length=255) + namespace_fingerprint: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + verification_receipt_id: UUID + verification_generation: int = Field(ge=0) + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(ge=0) + media_type: str = Field(min_length=1, max_length=255) + + @model_validator(mode="after") + def bind_artifact_binding(self): + """Use the exact immutable binding as the prepared resource selector.""" + if self.resource_id != self.binding_id: + raise ValueError("guide read resource must match binding") + return self + + AuthorizationResourceContext = ( ActorSelfResourceContext | ProjectReadResourceContext @@ -1253,6 +1317,8 @@ def bind_page_size(self): | ArtifactPutAttemptResourceContext | ArtifactVerificationJobResourceContext | ArtifactPendingWorkResourceContext + | GuideSourceBindingResourceContext + | GuideSourceReadResourceContext ) @@ -1334,6 +1400,8 @@ class AuthorizationDecision(BaseModel): "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", + "guide_source_binding", + "guide_source_read", ] resource_id: ( UUID diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index a281ba072..b107a9a58 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -55,6 +55,7 @@ PREPARED_MUTATION_REQUESTS = CANONICAL_REQUESTS - { "ArtifactRecoveryRequest", "GuideSufficiencyMaterialRequest", + "GuideSourceMaterializationRequest", } PREPARED_HANDLE_FORBIDDEN_ROOTS = ( APP_ROOT / "adapters", @@ -453,6 +454,19 @@ def test_durable_artifact_mutation_ports_require_process_local_prepared_authorit assert "ReadyUploadSetRequest" not in source assert "ActionId" not in source + materialization = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "GuideSourceMaterializationRequest" + ) + materialization_fields = { + node.target.id: _annotation_names(node.annotation) + for node in materialization.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + } + assert materialization_fields["idempotency_key"] == {"UUID"} + assert "prepared_authorization" not in materialization_fields + expected_methods = { "GuideArtifactIngestPort": {"ingest"}, "SubmissionBundlePreparationPort": {"prepare"}, diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 69691eb8e..5c8ba0add 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -201,6 +201,8 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, } artifact_allowed = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 8696fdc47..aba95ccbf 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -61,6 +61,16 @@ from app.modules.authorization import catalogue as authorization_catalogue from app.modules.authorization import kernel as authorization_kernel from app.modules.authorization import router as authorization_router +import app.modules.artifacts.authorization as artifact_authorization +from app.modules.artifacts.authorization import ( + PreparedGuideSourceBindingAuthorization, + PreparedGuideSourceReadAuthorization, +) +from app.modules.artifacts.schemas import ( + ArtifactAuthorityDeniedError, + GuideSourceBindingAuthorityFacts, + GuideSourceReadAuthorityFacts, +) from app.modules.authorization.admin_schemas import AdminRoleGrantRevokeBody from app.modules.authorization.lifecycle_schemas import ( ActorLifecycleBody, @@ -174,6 +184,8 @@ ActorSelfResourceContext, ActorStatus, ArtifactVerificationJobResourceContext, + GuideSourceBindingResourceContext, + GuideSourceReadResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -1598,13 +1610,13 @@ async def consume_rate() -> None: ), "artifact.guide_source.read": ( "artifact.guide_source.read", - "WS-AUTH-001-ART-03", - "planned", + "WS-XINT-002-04B", + "active", ), "artifact.guide_source.binding.create": ( "artifact.binding.create", - "WS-AUTH-001-ART-03", - "planned", + "WS-XINT-002-04B", + "active", ), "artifact.submission_bundle.prepare": ( "submission.create", @@ -1958,6 +1970,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, ActionId.PROJECT_ACTIVE_GUIDE_READ, ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -1990,7 +2004,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> for owner in { ActionOwner.AUTH_ART_02D_OPERATOR, ActionOwner.AUTH_ART_02D_INTERNAL, - ActionOwner.AUTH_ART_03, + ActionOwner.XINT_002_04B, ActionOwner.AUTH_ART_04B, ActionOwner.AUTH_ART_05, ActionOwner.AUTH_ART_06A, @@ -2002,7 +2016,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.AUTH_ART_03: 2, + ActionOwner.XINT_002_04B: 2, ActionOwner.AUTH_ART_04B: 1, ActionOwner.AUTH_ART_05: 1, ActionOwner.AUTH_ART_06A: 1, @@ -2044,14 +2058,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 41 + == 43 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 55 + == 53 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2516,6 +2530,8 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, } assert { action @@ -2540,6 +2556,7 @@ def _parse_custody_table(document: Path, expected_actions: set[str]) -> dict[str "| AUTH activation custodian | Exact planned ActionIds |", "| AUTH activation custodian | Exact ActionIds and current availability |", "| AUTH activation chunk | Exact planned ActionIds |", + "| AUTH activation chunk | Exact ActionIds and current availability |", }: continue parsed: dict[str, str] = {} @@ -2575,7 +2592,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - expected_owner_counts = { "WS-AUTH-001-ART-02D-OPERATOR": 8, "WS-AUTH-001-ART-02D-INTERNAL": 3, - "WS-AUTH-001-ART-03": 2, + "WS-XINT-002-04B": 2, "WS-XINT-002-04A": 1, "WS-XINT-002-05A": 1, "WS-AUTH-001-ART-04B": 1, @@ -2621,7 +2638,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations assert ( - "71 PermissionIds, 96 ActionIds, 37 active actions, and\n59 planned actions" in operations + "71 PermissionIds, 96 ActionIds, 43 active actions, and\n53 planned actions" in operations ) @@ -2717,7 +2734,7 @@ def test_fixed_service_action_matrix_rejects_metadata_drift( if metadata == "permission": changed = replace(definition, permission_id=PermissionId.ARTIFACT_PENDING_WORK_SCAN) elif metadata == "owner": - changed = replace(definition, owner=ActionOwner.AUTH_ART_03) + changed = replace(definition, owner=ActionOwner.AUTH_ART_05) else: changed = replace(definition, availability=ActionAvailability.PLANNED) action_index = dict(ACTION_BY_ID) @@ -5133,6 +5150,391 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): assert evidence.events == [] +@pytest.mark.parametrize( + ("action_id", "service_identity", "resource_type"), + [ + ( + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ServiceIdentity.ARTIFACT_BINDING, + "guide_source_binding", + ), + ( + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ServiceIdentity.ARTIFACT_GUIDE_READER, + "guide_source_read", + ), + ], +) +@pytest.mark.asyncio +async def test_prepared_guide_service_authority_is_exact_and_single_use( + action_id: ActionId, + service_identity: ServiceIdentity, + resource_type: str, +) -> None: + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + service_identity=service_identity, + ) + 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=service_identity.value, + ), + ) + + facts = LockedServiceFacts() + + async def revalidate(current: ServiceAuthorizationContext, _action: ActionId): + return current + + authorization, evidence = _runtime_service( + context, + session=session, + admin_repository=facts, + revalidate_service=revalidate, + ) + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + facts, + ) + project_id = uuid4() + guide_id = uuid4() + snapshot_id = uuid4() + item_id = uuid4() + setup_run_id = uuid4() + content_id = uuid4() + replica_id = uuid4() + if resource_type == "guide_source_binding": + resource = GuideSourceBindingResourceContext( + resource_type="guide_source_binding", + resource_id=item_id, + project_id=project_id, + guide_id=guide_id, + guide_source_snapshot_id=snapshot_id, + guide_source_item_id=item_id, + project_setup_run_id=setup_run_id, + setup_generation=1, + content_id=content_id, + verified_replica_id=replica_id, + sha256="sha256:" + "1" * 64, + byte_count=10, + logical_role="guide_source_original", + ) + else: + binding_id = uuid4() + resource = GuideSourceReadResourceContext( + resource_type="guide_source_read", + resource_id=binding_id, + project_id=project_id, + guide_id=guide_id, + guide_source_snapshot_id=snapshot_id, + guide_source_item_id=item_id, + project_setup_run_id=setup_run_id, + setup_generation=1, + binding_id=binding_id, + content_id=content_id, + verified_replica_id=replica_id, + storage_namespace_id="guide-source", + namespace_fingerprint="sha256:" + "2" * 64, + verification_receipt_id=uuid4(), + verification_generation=1, + sha256="sha256:" + "1" * 64, + byte_count=10, + media_type="application/pdf", + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type=resource_type, + artifact_resource_id=resource.resource_id, + ) + handle = await prepared.prepare(action_id, caller_input, scope) + wrong_resource_id = uuid4() + selector_update = {"resource_id": wrong_resource_id} + if isinstance(resource, GuideSourceBindingResourceContext): + selector_update["guide_source_item_id"] = wrong_resource_id + else: + selector_update["binding_id"] = wrong_resource_id + mismatched = resource.model_copy(update=selector_update) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume(handle, action_id, caller_input, mismatched) + assert evidence.events == [] + + decision = await prepared.consume(handle, action_id, caller_input, resource) + assert decision.allowed is True + assert decision.resource_context_digest == authorization_resource_digest(resource) + assert len(evidence.events) == 1 + assert evidence.events[0].after_facts["resource_context_digest"] == ( + decision.resource_context_digest + ) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume(handle, action_id, caller_input, resource) + + +@pytest.mark.parametrize("authority_kind", ["binding", "read"]) +@pytest.mark.asyncio +async def test_production_guide_service_adapter_rejects_every_fact_mismatch_and_replay( + authority_kind: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = _PreparedTestSession() + service_identity = ( + ServiceIdentity.ARTIFACT_BINDING + if authority_kind == "binding" + else ServiceIdentity.ARTIFACT_GUIDE_READER + ) + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + service_identity=service_identity, + ) + assert isinstance(context, ServiceAuthorizationContext) + + async def fixed_context(*_args): + return context + + class FakePrepared: + consumed = 0 + + def __init__(self, *_args) -> None: + self.handle = object.__new__(PreparedAuthorizationHandle) + + async def prepare(self, *_args): + return self.handle + + async def consume(self, handle, *_args): + assert handle is self.handle + self.consumed += 1 + + def close(self) -> None: + return None + + monkeypatch.setattr(artifact_authorization, "_fixed_service_context", fixed_context) + monkeypatch.setattr(artifact_authorization, "PreparedAuthorizationService", FakePrepared) + + common = { + "project_id": uuid4(), + "guide_id": uuid4(), + "guide_source_snapshot_id": uuid4(), + "guide_source_item_id": uuid4(), + "project_setup_run_id": uuid4(), + "setup_generation": 1, + "content_id": uuid4(), + "verified_replica_id": uuid4(), + "sha256": "sha256:" + "1" * 64, + "byte_count": 10, + } + if authority_kind == "binding": + facts = GuideSourceBindingAuthorityFacts( + **common, + logical_role="guide_source_original", + ) + authority = PreparedGuideSourceBindingAuthorization( + session, # type: ignore[arg-type] + request_id=uuid4(), + correlation_id=uuid4(), + ) + else: + facts = GuideSourceReadAuthorityFacts( + **common, + binding_id=uuid4(), + storage_namespace_id="guide-source", + namespace_fingerprint="sha256:" + "2" * 64, + verification_receipt_id=uuid4(), + verification_generation=1, + media_type="application/pdf", + ) + authority = PreparedGuideSourceReadAuthorization( + session, # type: ignore[arg-type] + request_id=uuid4(), + correlation_id=uuid4(), + ) + + handle = await authority.prepare(facts=facts, idempotency_key=uuid4()) + with pytest.raises(ArtifactAuthorityDeniedError, match="invalid"): + await authority.consume( + prepared_authorization=object.__new__(PreparedAuthorizationHandle), + facts=facts, + ) + + for field_name in facts.__dataclass_fields__: + original = getattr(facts, field_name) + if isinstance(original, UUID): + changed = uuid4() + elif isinstance(original, int): + changed = original + 1 + else: + changed = f"changed-{original}" + with pytest.raises(ArtifactAuthorityDeniedError, match="invalid"): + await authority.consume( + prepared_authorization=handle, + facts=replace(facts, **{field_name: changed}), + ) + + await authority.consume(prepared_authorization=handle, facts=facts) + with pytest.raises(ArtifactAuthorityDeniedError, match="invalid"): + await authority.consume(prepared_authorization=handle, facts=facts) + + +@pytest.mark.asyncio +async def test_fixed_service_context_rejects_mismatched_loaded_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class MismatchedActorRepository: + def __init__(self, _session) -> None: + pass + + async def get_service_actor(self, _service_identity: str): + return SimpleNamespace( + id=str(uuid4()), + service_identity=ServiceIdentity.ARTIFACT_GUIDE_READER.value, + ) + + monkeypatch.setattr( + artifact_authorization, + "ActorRepository", + MismatchedActorRepository, + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="principal is unavailable"): + await artifact_authorization._fixed_service_context( + _PreparedTestSession(), # type: ignore[arg-type] + ServiceIdentity.ARTIFACT_BINDING, + uuid4(), + uuid4(), + ) + + +@pytest.mark.parametrize( + ("action_id", "resource_type", "wrong_identity"), + [ + ( + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + "guide_source_binding", + ServiceIdentity.ARTIFACT_GUIDE_READER, + ), + ( + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + "guide_source_read", + ServiceIdentity.ARTIFACT_BINDING, + ), + ], +) +@pytest.mark.asyncio +async def test_prepared_guide_actions_deny_wrong_fixed_service_before_actor_lock( + action_id: ActionId, + resource_type: str, + wrong_identity: ServiceIdentity, +) -> None: + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + service_identity=wrong_identity, + ) + session = _PreparedTestSession() + + class NoActorLock: + calls = 0 + + async def lock_request_actor(self, *_args): + self.calls += 1 + raise AssertionError("wrong service must deny before actor locking") + + repository = NoActorLock() + authorization, evidence = _runtime_service( + context, + session=session, + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + repository, # 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.ARTIFACT_INTERNAL, + artifact_resource_type=resource_type, + artifact_resource_id=uuid4(), + ), + ) + assert exc_info.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert repository.calls == 0 + assert evidence.events == [] + + +@pytest.mark.parametrize( + ("action_id", "resource_type"), + [ + ( + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + "guide_source_binding", + ), + ( + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + "guide_source_read", + ), + ], +) +@pytest.mark.asyncio +async def test_human_authority_cannot_substitute_for_fixed_guide_services( + action_id: ActionId, + resource_type: str, +) -> None: + context = _runtime_context() + session = _PreparedTestSession() + + class NoHumanGrantLookup: + grant_calls = 0 + + async def find_effective_grant(self, *_args, **_kwargs): + self.grant_calls += 1 + return SimpleNamespace(id=str(uuid4()), status="active") + + repository = NoHumanGrantLookup() + authorization, evidence = _runtime_service( + context, + session=session, + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + repository, # 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.ARTIFACT_INTERNAL, + artifact_resource_type=resource_type, + artifact_resource_id=uuid4(), + ), + ) + assert exc_info.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert repository.grant_calls == 0 + assert evidence.events == [] + + @pytest.mark.parametrize( ("action_id", "service_identity"), tuple( diff --git a/backend/tests/test_guide_bindings.py b/backend/tests/test_guide_bindings.py index 52c1ac6d6..bc065b002 100644 --- a/backend/tests/test_guide_bindings.py +++ b/backend/tests/test_guide_bindings.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import AsyncIterator +from dataclasses import replace from datetime import UTC, datetime import hashlib from io import BytesIO @@ -19,7 +20,7 @@ from alembic import command from alembic.config import Config from sqlalchemy import func, select, text -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.core.hashing import canonical_json_hash @@ -31,6 +32,11 @@ from app.interfaces.project_agents import GuideSourceMaterial, GuideSufficiencyAgentResult from app.interfaces.artifacts import ArtifactObjectMissingError, ArtifactStoreUnavailableError from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.artifacts.authorization import ( + PreparedGuideSourceBindingAuthorization, + PreparedGuideSourceReadAuthorization, +) from app.modules.artifacts.guide_bindings import ( GuideSourceBindingError, GuideSourceBindingService, @@ -522,6 +528,13 @@ class _AllowReadAuthority: def __init__(self, *, handle: PreparedAuthorizationHandle | None = None) -> None: self.handle = handle or object.__new__(PreparedAuthorizationHandle) self.facts: list[GuideSourceReadAuthorityFacts] = [] + self.prepared_facts: list[GuideSourceReadAuthorityFacts] = [] + self.idempotency_keys: list[UUID] = [] + + async def prepare(self, **values: Any) -> PreparedAuthorizationHandle: + self.prepared_facts.append(values["facts"]) + self.idempotency_keys.append(values["idempotency_key"]) + return self.handle async def consume(self, **values: Any) -> None: assert values["prepared_authorization"] is self.handle @@ -915,6 +928,31 @@ async def _seed_binding_lineage( return ids +def _service_principal( + service_identity: ServiceIdentity, +) -> tuple[ActorProfile, ActorIdentityLink]: + profile_id, link_id = uuid4(), uuid4() + return ( + ActorProfile( + id=str(profile_id), + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=service_identity.value, + created_by="test", + ), + ActorIdentityLink( + id=str(link_id), + actor_profile_id=str(profile_id), + issuer="https://issuer.example.test", + subject=service_identity.value, + subject_kind="service", + status="active", + linked_by="test", + ), + ) + + def _request(ids: dict[str, UUID], authority: _AllowBindingAuthority, **changes: Any): values = { "prepared_authorization": authority.handle, @@ -935,11 +973,10 @@ def _materialization_request( ids: dict[str, UUID], *, binding_id: UUID, - authority: _AllowReadAuthority, **changes: Any, ) -> GuideSourceMaterializationRequest: values = { - "prepared_authorization": authority.handle, + "idempotency_key": uuid4(), "project_id": ids["project"], "guide_id": ids["guide"], "guide_source_snapshot_id": ids["snapshot"], @@ -1539,7 +1576,6 @@ async def test_materialization_denies_before_provider_read( session, sha256=digest, byte_count=len(payload), media_type="application/pdf" ) binding_id = await _create_binding(factory, ids) - authority = _AllowReadAuthority() service = ArtifactMaterializationService( factory, store, # type: ignore[arg-type] @@ -1550,7 +1586,7 @@ async def test_materialization_denies_before_provider_read( with pytest.raises(ArtifactAuthorityDeniedError, match="unavailable"): await service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) + _materialization_request(ids, binding_id=binding_id) ) assert store.open_count == 0 @@ -1586,7 +1622,7 @@ async def test_materialization_verifies_classifies_replays_and_cleans_scratch( _namespace(), authority_factory=lambda _: authority, ) - request = _materialization_request(ids, binding_id=binding_id, authority=authority) + request = _materialization_request(ids, binding_id=binding_id) first = await service.materialize_guide_source(request) replay = await service.materialize_guide_source(request) @@ -1599,6 +1635,8 @@ async def test_materialization_verifies_classifies_replays_and_cleans_scratch( assert replay.classification_id == first.classification_id assert replay.replayed assert store.open_count == 2 + assert authority.prepared_facts == authority.facts + assert authority.idempotency_keys == [request.idempotency_key] * 2 assert authority.facts[0].namespace_fingerprint == "sha256:" + "b" * 64 assert authority.facts[0].verification_generation == 0 assert (await scratch.usage()).reservation_count == 0 @@ -1610,6 +1648,56 @@ async def test_materialization_verifies_classifies_replays_and_cleans_scratch( await engine.dispose() +@pytest.mark.asyncio +async def test_materialization_prepares_live_reader_authority_in_owned_session( + isolated_database_env: str, + tmp_path: Path, +) -> None: + payload = b"%PDF-1.7\nverified" + digest = "sha256:" + hashlib.sha256(payload).hexdigest() + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + preparation, scratch = _preparation(tmp_path) + store = _ReadStore(payload) + try: + async with factory() as session: + ids = await _seed_binding_lineage( + session, + sha256=digest, + byte_count=len(payload), + media_type="application/pdf", + ) + profile, link = _service_principal(ServiceIdentity.ARTIFACT_GUIDE_READER) + session.add_all((profile, link)) + await session.commit() + binding_id = await _create_binding(factory, ids) + service = ArtifactMaterializationService( + factory, + store, # type: ignore[arg-type] + preparation, + GuideFormatDetector(GuideFormatLimits()), + _namespace(), + authority_factory=lambda session: PreparedGuideSourceReadAuthorization( + session, + request_id=uuid4(), + correlation_id=uuid4(), + ), + ) + + result = await service.materialize_guide_source( + _materialization_request( + ids, + binding_id=binding_id, + ) + ) + + assert result.binding_id == binding_id + assert store.open_count == 1 + finally: + scratch.close() + await engine.dispose() + + @pytest.mark.asyncio async def test_materialization_rejects_conflicting_immutable_classification( isolated_database_env: str, tmp_path: Path @@ -1635,7 +1723,7 @@ async def test_materialization_rejects_conflicting_immutable_classification( _namespace(), authority_factory=lambda _: authority, ) - request = _materialization_request(ids, binding_id=binding_id, authority=authority) + request = _materialization_request(ids, binding_id=binding_id) first = await service.materialize_guide_source(request) async with factory() as session, session.begin(): persisted = await session.get( @@ -1687,7 +1775,7 @@ async def test_truncated_materialization_records_incident_without_classification with pytest.raises(GuideSourceMaterializationError, match="incident"): await service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) + _materialization_request(ids, binding_id=binding_id) ) async with factory() as session: @@ -1731,7 +1819,7 @@ async def test_same_size_changed_materialization_records_incident( with pytest.raises(GuideSourceMaterializationError, match="incident"): await service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) + _materialization_request(ids, binding_id=binding_id) ) async with factory() as session: @@ -1747,7 +1835,7 @@ async def test_same_size_changed_materialization_records_incident( @pytest.mark.asyncio -async def test_post_read_lineage_drift_records_stale_incident( +async def test_authorized_read_locks_lineage_through_provider_access( isolated_database_env: str, tmp_path: Path ) -> None: payload = b"%PDF-1.7\nverified" @@ -1756,6 +1844,7 @@ async def test_post_read_lineage_drift_records_stale_incident( factory = async_sessionmaker(engine, expire_on_commit=False) preparation, scratch = _preparation(tmp_path) authority = _AllowReadAuthority() + blocked = False try: async with factory() as session: ids = await _seed_binding_lineage( @@ -1764,27 +1853,18 @@ async def test_post_read_lineage_drift_records_stale_incident( binding_id = await _create_binding(factory, ids) async def advance_setup_generation() -> None: - async with factory() as session, session.begin(): - async with suspend_historical_product_custody( - session, - table="project_setup_runs", - triggers=("source_setup_run_custody",), - ): - session.add( - ProjectSetupRun( - id=str(uuid4()), - project_id=str(ids["project"]), - guide_id=str(ids["guide"]), - guide_version="v1", - source_snapshot_id=str(ids["snapshot"]), - source_snapshot_hash=canonical_json_hash({"item": str(ids["item"])}), - setup_generation=2, - status="queued", - current_step="queued", - created_by="test", - ) + nonlocal blocked + try: + async with factory() as session, session.begin(): + await session.execute(text("SET LOCAL lock_timeout = '100ms'")) + await session.scalar( + select(ProjectSetupRun) + .where(ProjectSetupRun.id == str(ids["run"])) + .with_for_update() ) - await session.flush() + except DBAPIError as exc: + assert getattr(exc.orig, "sqlstate", None) == "55P03" + blocked = True service = ArtifactMaterializationService( factory, @@ -1795,16 +1875,15 @@ async def advance_setup_generation() -> None: authority_factory=lambda _: authority, ) - with pytest.raises(GuideSourceMaterializationError, match="incident"): - await service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) - ) + result = await service.materialize_guide_source( + _materialization_request(ids, binding_id=binding_id) + ) async with factory() as session: - incident = await session.scalar(select(GuideSourceArtifactIncident)) - assert incident is not None - assert incident.code == "stale" - assert await session.scalar(select(func.count(GuideSourceFormatClassification.id))) == 0 + assert blocked + assert result.binding_id == binding_id + assert await session.scalar(select(GuideSourceArtifactIncident)) is None + assert await session.scalar(select(func.count(GuideSourceFormatClassification.id))) == 1 finally: scratch.close() await engine.dispose() @@ -1858,7 +1937,6 @@ async def test_cross_resource_materialization_denies_before_authority_and_provid _materialization_request( ids, binding_id=request_binding_id, - authority=authority, **request_changes, ) ) @@ -1905,7 +1983,7 @@ async def test_composed_namespace_drift_denies_before_authority_and_provider_rea with pytest.raises(ArtifactStorageNamespaceError, match="active storage namespace"): await service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) + _materialization_request(ids, binding_id=binding_id) ) assert store.open_count == 0 @@ -1942,7 +2020,7 @@ async def test_materialization_cancellation_cleans_scratch_without_effect( ) task = asyncio.create_task( service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) + _materialization_request(ids, binding_id=binding_id) ) ) await asyncio.wait_for(store.started.wait(), timeout=5) @@ -1962,7 +2040,9 @@ async def test_materialization_cancellation_cleans_scratch_without_effect( @pytest.mark.asyncio async def test_materialization_inspection_timeout_cleans_scratch_and_records_incident( - isolated_database_env: str, tmp_path: Path + isolated_database_env: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: payload = b"%PDF-1.7\nverified" digest = "sha256:" + hashlib.sha256(payload).hexdigest() @@ -1992,7 +2072,7 @@ async def deterministic_timeout(*_args: Any, **_kwargs: Any) -> None: with pytest.raises(GuideSourceMaterializationError, match="incident"): await service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) + _materialization_request(ids, binding_id=binding_id) ) assert (await scratch.usage()).reservation_count == 0 @@ -2001,6 +2081,18 @@ async def deterministic_timeout(*_args: Any, **_kwargs: Any) -> None: assert incident is not None assert incident.code == "unavailable" assert await session.scalar(select(func.count(GuideSourceFormatClassification.id))) == 0 + + async def fail_incident_write(*_args: Any, **_kwargs: Any) -> None: + raise SQLAlchemyError("incident write unavailable") + + monkeypatch.setattr(service, "_record_incident", fail_incident_write) + with pytest.raises(GuideSourceMaterializationError, match="incident"): + await service.materialize_guide_source( + _materialization_request(ids, binding_id=binding_id) + ) + assert (await scratch.usage()).reservation_count == 0 + async with factory() as session: + assert await session.scalar(select(func.count(GuideSourceArtifactIncident.id))) == 1 finally: scratch.close() await engine.dispose() @@ -2044,7 +2136,7 @@ async def test_provider_failure_records_only_bounded_artifact_incident( with pytest.raises(GuideSourceMaterializationError, match="incident"): await service.materialize_guide_source( - _materialization_request(ids, binding_id=binding_id, authority=authority) + _materialization_request(ids, binding_id=binding_id) ) async with factory() as session: @@ -2087,6 +2179,52 @@ async def test_binding_is_exact_immutable_and_idempotent(isolated_database_env: await engine.dispose() +@pytest.mark.asyncio +async def test_binding_uses_live_fixed_service_prepared_authority( + isolated_database_env: str, +) -> None: + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session: + ids = await _seed_binding_lineage(session) + profile, link = _service_principal(ServiceIdentity.ARTIFACT_BINDING) + session.add_all((profile, link)) + await session.commit() + + async with factory() as session, session.begin(): + authority = PreparedGuideSourceBindingAuthorization( + session, + request_id=uuid4(), + correlation_id=uuid4(), + ) + facts = GuideSourceBindingAuthorityFacts( + project_id=ids["project"], + guide_id=ids["guide"], + guide_source_snapshot_id=ids["snapshot"], + guide_source_item_id=ids["item"], + project_setup_run_id=ids["run"], + setup_generation=1, + content_id=ids["content"], + verified_replica_id=ids["replica"], + sha256="sha256:" + "a" * 64, + byte_count=42, + logical_role="guide_source_original", + ) + handle = await authority.prepare(facts=facts, idempotency_key=uuid4()) + request = replace( + _request(ids, _AllowBindingAuthority()), + prepared_authorization=handle, + ) + result = await GuideSourceBindingService(session, authority).bind_guide_source(request) + assert not result.replayed + + async with factory() as session: + assert await session.scalar(select(func.count(GuideSourceArtifactBinding.id))) == 1 + finally: + await engine.dispose() + + @pytest.mark.asyncio async def test_next_generation_explicitly_supersedes_prior_binding( isolated_database_env: str, @@ -2299,6 +2437,8 @@ async def _create_populated_incident(database_url: str) -> None: "cross_guide", "wrong_run", "stale_generation", + "wrong_content", + "wrong_logical_role", ], ) async def test_binding_fails_closed_before_authority_or_effect( @@ -2346,6 +2486,14 @@ async def test_binding_fails_closed_before_authority_or_effect( guide_id=uuid4() if failure == "cross_guide" else ids["guide"], source_item_id=uuid4() if failure == "missing_item" else ids["item"], project_setup_run_id=uuid4() if failure == "wrong_run" else ids["run"], + verified_content_id=( + uuid4() if failure == "wrong_content" else ids["content"] + ), + logical_role=( + "submission_original" + if failure == "wrong_logical_role" + else "guide_source_original" + ), ) with pytest.raises(GuideSourceBindingError): async with factory() as session, session.begin(): diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 5a52c81b9..9e368f6ae 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -709,13 +709,13 @@ reconciliation uses migration `0036`. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 71 PermissionIds, 96 ActionIds, 37 active actions, and -59 planned actions after AUTH-12A registers eighteen project-mutation actions -without activation. AUTH-11C2 activates three current effective-policy and -active-guide reads in addition to AUTH-11C1's six diagnostic reads. The exact route mapping -is in `docs/spec_authorization_service.md`. WS-XINT-002-04A activates only -guide-source ingest; the other 18 ART actions remain planned, including every -Operator artifact action. +Catalogue totals are 71 PermissionIds, 96 ActionIds, 43 active actions, and +53 planned actions. AUTH-11C2 activates three current effective-policy and +active-guide reads in addition to AUTH-11C1's six diagnostic reads. The exact +route mapping is in `docs/spec_authorization_service.md`. WS-XINT-002-04A +activates Project Manager guide-source ingest, and WS-XINT-002-04B activates +only the fixed-service guide binding and read actions. The other 16 ART actions +remain planned, including every Operator artifact action. Migration `0037` keeps each allowed or denied internal ART decision bound to the exact privacy-bounded resource-context digest in append-only audit facts. @@ -779,8 +779,11 @@ not this prepared authorization handle. PREP currently supports actor-self profile update, the eight active AdminRoleGrant-backed administrative mutations, the three active fixed-service -ART actions, and Project Manager `artifact.guide_source.ingest`. No other -production feature command is cut over. Callers begin and own one root transaction, call `prepare`, +ART foundation actions, Project Manager `artifact.guide_source.ingest`, and the +fixed-service `artifact.guide_source.binding.create` and +`artifact.guide_source.read` actions. Submission, checker, review, and generic +artifact-read actions remain planned; no other production feature command is +cut over. Callers begin and own one root transaction, call `prepare`, lock their participant rows, compose final typed facts, call `consume` with the independently expected ActionId and the same strict request/idempotency input, flush participant work, and commit once. AUTH never commits in dependency @@ -790,15 +793,18 @@ unchanged. The handle remains consumed after every exact attempt, including a rolled-back or cancelled attempt, and dependency teardown invalidates all outstanding handles. -General human and administrative PREP callers do not restage a denial after +General human, administrative, and guide binding/read PREP callers do not +restage a denial after rollback; its staged decision belongs to the failed caller transaction and -rolls back with participant state. The three active internal ART 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. Still- -planned fixed-service preparation still issues no handle. When it enters the -ART adapter with an exact resource context, its bounded `action_unavailable` -denial follows the same rollback-then-clean-restage path. +rolls back with participant state. The three active internal ART foundation +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 +foundation action enters its ART adapter with an exact resource context, its +bounded `action_unavailable` denial follows the same rollback-then-clean-restage +path. Operationally, actor-self preparation locks profile then exact link. An administrative preparation locks `AuthorityControl(id=1)`, request profile, diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index cd0e17094..9010d27b9 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -376,7 +376,11 @@ schemas, outbox/Celery payloads, provider interfaces, or serialized contracts. `GuideArtifactIngestRequest` contains prepared authority, exact project, guide, guide-source snapshot and item IDs, logical role, media type, and authorized byte source. It accepts no caller digest, size, content ID, or provider -reference. `SubmissionBundlePreparationRequest` contains +reference. `GuideSourceMaterializationRequest` contains an idempotency key and +the exact guide, source, setup-generation, and binding identifiers; it contains +no prepared handle. The materializer creates and consumes fresh fixed-reader +authority inside the same root transaction that locks the canonical read facts. +`SubmissionBundlePreparationRequest` contains 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 @@ -1233,14 +1237,23 @@ an artifact incident and never a guide-insufficiency result. The hidden v0.1 reader authorizes and locks the exact project, draft guide, source snapshot/item, setup run/generation, binding, content, verified replica, storage-namespace fingerprint, and terminal verification receipt/generation -before opening the provider object. It releases database locks during the full -provider read, then relocks and recomposes the same facts before persisting one -immutable format classification. Any drift records only a bounded ART incident. +before opening the provider object. It retains database locks throughout the +provider read and releases them only after the verified bytes are materialized, +classified, and the immutable classification is staged in that same root +transaction. Exact +guide, snapshot, item, setup generation, binding, content, replica, namespace, +job, and receipt locks prevent lineage advancement between authorization and +provider access. Known stale lineage denies before provider I/O. Classification records contain the server-observed digest, size, canonical media type, detector identity/version, detected format, outcome, and bounded structural facts; they contain no source filenames, provider references, raw -document text, or parser exception strings. `artifact.guide_source.read` -remains unavailable until AUTH-04B installs the fixed guide-reader adapter. +document text, or parser exception strings. `WS-XINT-002-04B` activates +`artifact.guide_source.read` only for `workstream.artifact.guide_reader` and +`artifact.guide_source.binding.create` only for +`workstream.artifact.binding`. Each fresh process-local capability is bound to +the complete locked lineage, setup generation, verified content and replica +facts, and the caller-owned root transaction before provider access or binding +mutation. Project Manager ingest authority implies neither service action. Typed extractors run in a bounded no-network isolation boundary. Immutable attempt evidence carries bounded status/error facts. A separate successful diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 3a74d072d..0590f49a5 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -237,9 +237,11 @@ permissions are the exact 22 post-`0020` permissions. AUTH-07A, AUTH-11A, and WS-XINT-002-01 add their matching typed/SQL audit parity without making them executable. -The closed action registry contains 78 rows after AUTH-11C2: 37 active actions -and 41 planned rows before AUTH-12A. AUTH-12A adds eighteen planned -project-mutation rows, producing 96 total actions: 37 active and 59 planned. +The closed action registry contained 78 rows after AUTH-11C2: 37 active actions +and 41 planned rows before AUTH-12A. AUTH-12A added eighteen planned +project-mutation rows, producing the historical 96-row state of 37 active and +59 planned. Later project-mutation and ART activation chunks advance the current +state to 43 active and 53 planned without adding identifiers. AUTH-10A added five project-role read/manage rows; AUTH-10B owns and activates the three reads, while AUTH-10C owns and activates the two reason-bound, idempotent project-role mutations. AUTH-11A adds eleven @@ -255,8 +257,10 @@ provisioning actions without activating a route; AUTH-09B activates only `actor.service.provision`, AUTH-09C activates only `actor.profile.read` and `actor.identity_link.read`, AUTH-09D-A activates the three profile lifecycle actions, and AUTH-09D-B activates the two identity-link lifecycle actions. The -other registry rows cover three planned Operator recovery actions, 19 other -artifact actions—18 planned plus active `artifact.guide_source.ingest`—canonical +other registry rows cover three planned Operator recovery actions and the ART +catalogue: 16 planned, three active foundation-service actions, active +`artifact.guide_source.ingest`, and active fixed-service guide binding/read; +the remaining rows cover canonical `submission.create`, and 19 review actions. An action becomes active only when its feature owner has merged the canonical resource composer, guards, surface or command declaration, behavior tests, and transaction-local revalidation where @@ -372,7 +376,7 @@ The paired artifact hidden-behavior matrix is closed: | Resource-owning WS-ART chunk | Hidden actions/resources implemented by that chunk | |---|---| | `WS-ART-001-02D` | Operator binding/replica/receipt/verification-job/recovery-attempt/audit reads; the operations-domain `operations.artifact_storage_admission.read` action mapped to `operations.status.read`; verification retry; `artifact.verification.execute`; `artifact.pending_work.scan`; and `artifact.put_attempt.resolve` | -| `WS-ART-001-03` | `artifact.guide_source.ingest`, `artifact.guide_source.read`, and `artifact.guide_source.binding.create` mapped to `artifact.binding.create` | +| `WS-ART-001-03` | Hidden guide behavior for `artifact.guide_source.ingest -> artifact.guide_source.ingest`, `artifact.guide_source.read -> artifact.guide_source.read`, and `artifact.guide_source.binding.create -> artifact.binding.create`; AUTH activation custody is split between WS-XINT-002-04A and 04B below | | `WS-ART-001-04A` historical baseline | the former multi-step upload authority had no route/command and is deleted from the live catalogue by WS-XINT-002-01 without compatibility aliases | | `WS-ART-001-04A` through `04C` | one hidden `artifact.submission_bundle.prepare` surface mapped to `submission.create`; remains unavailable until 04C evidence and the later WS-XINT-002-05A activation contract | | `WS-ART-001-04B` | `artifact.pre_submit.checker_input.materialize` mapped to `artifact.checker_input.materialize` | @@ -391,8 +395,11 @@ permission belongs only to the Project Manager role and is evaluated through an active covered grant: system-scoped or exact-project. Its prepared capability locks the actor, exact identity link, and matched grant before byte intake; final consumption binds the ART-locked project, draft guide, snapshot, item, -operation/request digests, and server-computed byte facts. Guide-source read and -binding creation remain planned. +operation/request digests, and server-computed byte facts. `WS-XINT-002-04B` +separately activates guide-source read and binding creation for their exact +fixed service identities. Both use opaque transaction-bound PREP handles bound +to the complete verified-content and setup-generation facts; neither authority +is inherited from the Project Manager uploader. Every row requires AUTH-07A's registry and AUTH-07B's kernel first. A row with an Operator principal also requires its AUTH-08 grant definition; a row with a fixed service @@ -424,7 +431,7 @@ A mapping is not a permission alias. | `WS-AUTH-001-ART-02D-INTERNAL` | Active: `artifact.verification.execute`, `artifact.pending_work.scan`, `artifact.put_attempt.resolve` | | `WS-AUTH-001-ART-02D-OPERATOR` | `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | | `WS-XINT-002-04A` | Active: `artifact.guide_source.ingest` | -| `WS-AUTH-001-ART-03` | `artifact.guide_source.read`, `artifact.guide_source.binding.create` | +| `WS-XINT-002-04B` | Active: `artifact.guide_source.read`, `artifact.guide_source.binding.create` | | `WS-XINT-002-05A` | `artifact.submission_bundle.prepare` | | `WS-AUTH-001-ART-04B` | `artifact.pre_submit.checker_input.materialize` | | `WS-AUTH-001-ART-05` | `artifact.submission.binding.create` | @@ -438,8 +445,9 @@ availability and only extends the evaluator to response slots. The `OPERATOR` suffix names future activation custody only; it creates no Operator grant or entitlement. WS-XINT-002-03 activates the three internal -service actions and WS-XINT-002-04A activates guide-source ingest; the other 18 -ART actions remain planned and unavailable. +service actions, WS-XINT-002-04A activates guide-source ingest, and +WS-XINT-002-04B activates the two fixed-service guide binding/read actions; the +other 16 ART actions remain planned and unavailable. Migration `0037` admits the exact privacy-bounded ART resource-context digest in append-only authorization decision facts; it adds no table or column. `artifact.verification_job.retry` requires its own later evaluator, guards, and @@ -460,7 +468,7 @@ remain planned and unavailable, and add no migration. | `artifact.audit.read` | `artifact.audit.read` | Operator | artifact audit scope | `02D` | | `operations.artifact_storage_admission.read` | `operations.status.read` | Operator | deployment artifact-storage namespace | `02D` | | `artifact.guide_source.ingest` | `artifact.guide_source.ingest` | exact covered Project Manager | guide-source snapshot item | `03` | -| `artifact.guide_source.read` | `artifact.guide_source.read` | fixed guide-reader service | guide-source snapshot item | `03` | +| `artifact.guide_source.read` | `artifact.guide_source.read` | fixed guide-reader service | guide-source binding and verified replica | `03` | | `artifact.submission_bundle.prepare` | `submission.create` | assigned contributor | exact task/admission context | `WS-XINT-002-05A` | | `artifact.guide_source.binding.create` | `artifact.binding.create` | fixed binding service | guide-source snapshot item | `03` | | `artifact.submission.binding.create` | `artifact.binding.create` | fixed binding service | submission | `05` | @@ -474,8 +482,9 @@ remain planned and unavailable, and add no migration. | `artifact.review_packet.materialize` | `artifact.review_packet.materialize` | fixed materializer service | exact active lease and Submission packet | `WS-XINT-002-07` | | `artifact.review_evidence.binding.create` | `artifact.binding.create` | fixed binding service | finding slot in 07A; response slot added by evaluator-only 07B | `WS-XINT-002-07` | -The owner cells above deliberately retain the exact runtime `ActionOwner`. -07A/07B are contract sub-waves, not new catalogue owner values. +The resource-owning chunk cells above identify ART hidden-behavior custody; they +are distinct from the AUTH activation-custodian table and runtime +`ActionOwner`. 07A/07B are contract sub-waves, not new catalogue owner values. The fixed internal service identities and their complete action sets are also closed: @@ -631,7 +640,11 @@ AUTH locks AuthorityControl first when final-admin safety applies The PREP foundation issues handles for `actor.profile.update_self`, the eight active AdminRoleGrant-backed administrative mutations, the three active fixed -ART service actions, and Project Manager `artifact.guide_source.ingest`. +ART foundation service actions, Project Manager +`artifact.guide_source.ingest`, and the fixed-service +`artifact.guide_source.binding.create` and `artifact.guide_source.read` +actions. Submission, checker, review, and generic artifact-read actions remain +planned and issue no handle. Actor-self preparation locks the exact caller profile and then its exact identity link. Administrative preparation locks `AuthorityControl(id=1)`, the exact request profile, exact request identity