diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index 99a8ac908..a64237146 100644 --- a/.agent-loop/CURRENT_STATE.md +++ b/.agent-loop/CURRENT_STATE.md @@ -28,7 +28,7 @@ authority; these records do not grant or withhold it. | Initiative | Durable state on `main` | Remaining boundary | |---|---|---| -| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Boundary foundation and TASK, PROJECT, CHECKER, ART hidden preparation, and ART admission-consumption module-level capabilities are complete through `WS-ARCH-001-02E`; no product route or AUTH action was activated | `WS-ARCH-001-02F` is the TASK-owned immutable Submission command and hidden composed transaction | +| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Boundary foundation and hidden submission capabilities are complete through `WS-ARCH-001-02F`, including the TASK-owned atomic Submission composition; no product route or AUTH action was activated | `WS-ARCH-001-02G` is the AUTH contributor-preparation activation boundary | | [WS-ART-001](initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md) | Active delivery initiative; verified ready-admission publication and hidden preparation are merged through `04C2` | Replace the non-executable 05-wave XINT/ART contracts with WS-ARCH-001 split public-API contracts before activation, Submission consumption, or live cutover | | [WS-AUTH-001](initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md) | Active delivery initiative; project-policy authority and unified compilation authorization are merged through `12I` | POL-03B consumes 12I next; remaining AUTH activation chunks wait for their exact hidden owner behavior | | [WS-CON-001](initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md) | Active delivery initiative; policy persistence and shared lifecycle audit are merged | Complete hidden services, contribution records, conditional awards, fulfillment, and reconciliation after named AUTH and REV gates | diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md index 27bfdef69..a79f29206 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md @@ -10,8 +10,8 @@ | `WS-ARCH-001-02C` | CHECKERS effective pre-submit plan and bounded execution-result public facts/ports | L1 | Merged PR #320; no contributor preparation action or public route activated | | `WS-ARCH-001-02D` | ART hidden preparation public API and private-edge migration | L1 | Complete; production remains deny-only | | `WS-ARCH-001-02E` | ART ready-admission consumption and binding hidden module-level capability | L1 | Complete; production remains deny-only and route-unreachable | -| `WS-ARCH-001-02F` | TASK-owned immutable Submission command and hidden composed transaction | L1 | Next durable boundary | -| `WS-ARCH-001-02G` | AUTH contributor preparation activation after the complete hidden path | L1 | Proposed after 02F | +| `WS-ARCH-001-02F` | TASK-owned immutable Submission command and hidden composed transaction | L1 | Complete; production remains deny-only and route-unreachable | +| `WS-ARCH-001-02G` | AUTH contributor preparation activation after the complete hidden path | L1 | Next durable boundary | | `WS-ARCH-001-02H` | AUTH human/fixed-service consumption activation | L1 | Proposed after 02G | | `WS-ARCH-001-02I` | Admission-only public API/dispatch cutover and complete legacy removal | L1 | Deferred after 02H plus split 03/04/05 remediation, revision, checker-output and REV admission prerequisites | | `WS-ARCH-001-03` | PROJECT/TASK guide, locked-context, task and assignment capability repairs | L1 | Non-executable placeholder; requires a split contract | diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md index ecb774a46..dea42a2dd 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md @@ -22,14 +22,16 @@ route to delivery composition, exposes the bounded ART request/result/command API, consumes TASK/PROJECT/CHECKER public capabilities, keeps AUTH handles opaque, and preserves deny-only availability. -- WS-ARCH-001-02E is complete in this change. ART exposes one deny-by-default +- WS-ARCH-001-02E is complete. ART exposes one deny-by-default ready-admission consumption port, validates exact TASK and ART lineage, serializes binding identity, persists the consumed Submission id/version, creates one provider-neutral generic binding, and proves replay, concurrency, rollback, and stable conflicts without activating a route or AUTH action. -- Next durable boundary: WS-ARCH-001-02F only, the TASK-owned immutable - Submission command and hidden composed transaction. Open pull requests show - transient ownership. +- WS-ARCH-001-02F is complete in this change. TASK owns the immutable + admission-backed Submission command and the adapter owns one hidden root + transaction; production remains deny-only and route-unreachable. +- Next durable boundary: WS-ARCH-001-02G only, AUTH contributor-preparation + activation. Open pull requests show transient ownership. - Repository housekeeping after PR #315 found no competing clean-up initiative: WS-ARCH-001 remains the general boundary owner, WS-AUTH-003 owns AUTH-specific debt, and test-structure repairs remain incremental with the diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02F-task-submission-composition.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02F-task-submission-composition.md index bb880a430..60c0855e6 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02F-task-submission-composition.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02F-task-submission-composition.md @@ -7,8 +7,9 @@ WS-ARCH-001 — Modular Monolith Boundaries ## Goal Implement the TASK-owned immutable Submission command and hidden application -composition that atomically consumes fresh human AUTH, fixed ART-binding AUTH, -ART admission/binding, and TASK persistence through public ports. +composition that atomically consumes deny-only human AUTH, deny-only fixed +ART-binding AUTH, the already-ready ART admission/binding, and TASK persistence +through public ports. ## Why this chunk exists @@ -41,18 +42,29 @@ backend/app/modules/tasks/api/** backend/app/modules/tasks/models.py backend/app/modules/tasks/repository.py backend/app/modules/tasks/service.py -backend/app/adapters/**/submission*.py +backend/app/modules/tasks/submission_composition.py +backend/app/adapters/tasks/__init__.py backend/app/main.py backend/alembic/versions/.py +backend/alembic/env.py backend/tests/test_tasks.py -backend/tests/test_submission_concurrency.py -backend/tests/test_submission_history.py +backend/tests/test_submission_composition.py +backend/tests/test_artifact_bindings_db.py +backend/scripts/run_test_lanes.py +backend/scripts/behavior_ownership.py backend/tests/test_alembic.py +backend/tests/conftest.py +backend/tests/authorization/guide_compilation/test_migration_contract.py +backend/tests/projects/guide_compilation/test_migration_contract.py backend/tests/architecture/test_module_boundaries.py .ci/module-boundaries/private-edge-debt.v1.json .ci/behavior-ownership/** .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02F-task-submission-composition.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md +.agent-loop/CURRENT_STATE.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02F-transaction-manifest.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02F-external-review-response.md docs/architecture_data_model.md ``` @@ -71,11 +83,15 @@ contribution dispatch; compatibility facade. transaction as every mutation and evidence row. Production wiring remains deny-only; this chunk proves denial/concealment and zero mutation, not a successful AUTH capability or complete business effect. -- [ ] `PreparedBundlePreSubmitEvidenceService.persist(...)` joins that root - transaction through its public port and never opens or commits an - independent transaction; an integration test proves a final-stage failure - rolls back the Submission, binding, admission transition, evidence rows, - and authorization evidence together. +- [ ] The command accepts only typed TASK-owned authority ports. It never + receives an AUTH prepared handle, raw authorization context, AUTH + repository/session, or private ART service. The deny-only implementations + conceal the unavailable capability before any protected mutation. +- [ ] Pre-submit evidence and the ready admission are immutable prerequisites + produced by the earlier preparation path; 02F neither re-persists nor + mutates checker evidence. An integration test proves a final-stage failure + rolls back the Submission, binding, admission transition, and any + transaction-local authorization evidence together. - [ ] Composition opens one unit of work and wires ports only; TASK command owns sequencing and each owner enforces its invariants. - [ ] Denial, cancellation and persistence failure roll back all effects; @@ -86,11 +102,30 @@ contribution dispatch; compatibility facade. `.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02F-transaction-manifest.md`. - [ ] The new command remains hidden/unreachable pending 02G-02I. +## Required lock and operation order + +1. Open one root transaction in the application composition adapter. +2. Consume/conceal the human `submission.create` authority through its typed + deny-only TASK-facing port before protected state is revealed or mutated. +3. Lock TASK, active assignment, and latest predecessor through TASK ownership. +4. Allocate the immutable Submission id and next version from the locked facts. +5. Insert and flush the provisional TASK-owned Submission identity/version so + ART's admission foreign key can be validated inside the same transaction. +6. Call the ART admission-consumption public port with that exact id, version, + and TASK context; ART locks its lineage and consumes fixed binding authority. +7. Attach the exact ART admission/content/binding references to the Submission + and flush the complete immutable row. +8. Consume/record any final TASK-owned authority evidence inside the same root + transaction, then let the composition adapter commit once. + +No step may commit independently. Denial or failure at any step rolls back the +whole root transaction. + ## Verification commands ```bash -(cd backend && .venv/bin/python -m ruff check app/modules/tasks app/adapters app/main.py tests/test_submission_concurrency.py) -(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/test_tasks.py tests/test_submission_concurrency.py tests/test_submission_history.py tests/test_alembic.py --cov=app.modules.tasks --cov-fail-under=90) +(cd backend && .venv/bin/python -m ruff check app/modules/tasks app/adapters/tasks tests/test_submission_composition.py) +(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/test_submission_composition.py tests/test_tasks.py tests/test_alembic.py --cov=app.modules.tasks --cov-fail-under=90) (cd backend && .venv/bin/python -m scripts.module_boundaries validate --protected-base origin/main) python3 scripts/check_stale_authorization_docs.py python3 scripts/check_stale_artifact_contracts.py @@ -112,3 +147,7 @@ lock order, deny-only zero effect, and absence of orchestration-domain drift. Stop if transaction atomicity requires public sessions/repositories, if ART must create Submission, or if the live route must change early. + +## Merge state + +- Outcome on merge: `complete` diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02F-transaction-manifest.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02F-transaction-manifest.md new file mode 100644 index 000000000..ca4716e5c --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02F-transaction-manifest.md @@ -0,0 +1,50 @@ +# WS-ARCH-001-02F Transaction Manifest + +## Capability + +The hidden `SubmissionCreationCommand` creates one TASK-owned immutable +Submission from one already-ready ART admission. It remains route-unreachable +and production authorization remains deny-only. + +## Transaction and lock order + +1. The TASK adapter opens one root SQLAlchemy transaction. +2. Human `submission.create` authority is checked through the TASK-owned typed + authority port before TASK state is revealed. +3. TASK locks the task, active assignment, and latest predecessor. +4. TASK allocates the Submission UUID and version. +5. TASK inserts and flushes the provisional Submission identity/version. +6. ART consumes the exact admission and fixed binding authority through its + public port, locking ART lineage and binding scope. +7. TASK completes and flushes the immutable Submission with admission, binding, + content, assignment, predecessor, and locked policy references. +8. TASK consumes final human authority using the allocated identity/version. +9. The adapter commits once; every exception or cancellation rolls back all + participants. + +## Public facts and ports + +- `SubmissionCreationRequest` carries contributor-authored summary and + attestation plus server-selected TASK/assignment/admission/predecessor IDs. +- `SubmissionCreationAuthorizationPort` exposes only preliminary and final + TASK facts; no AUTH handle, context, repository, or session crosses TASK. +- `SubmissionArtifactAdmissionPort` is the TASK-owned participant protocol; + the composition adapter translates it to ART's public + `SubmissionAdmissionConsumptionPort`. +- `SubmissionCreationResult` returns only Submission, admission, binding, and + content identities. + +## Protected mutations + +- TASK: one immutable `submissions` row. +- ART: one admission terminal transition and one generic artifact binding. +- AUTH: transaction-local decision evidence only after later activation. + +Pre-submit checker evidence is an immutable prerequisite and is not mutated by +this command. + +## Deny-only state + +`DenySubmissionCreationAuthorization` rejects before TASK locks or mutation. +No route, action catalogue entry, or production AUTH adapter is activated by +02F. Positive complete-effect and concurrency proof remains owned by 02H. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02F-external-review-response.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02F-external-review-response.md new file mode 100644 index 000000000..04270465a --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02F-external-review-response.md @@ -0,0 +1,29 @@ +# WS-ARCH-001-02F External Review Response + +## Comments addressed + +- Added every changed planning, state, and PostgreSQL proof file to the chunk's + allowed mutation scope. +- Restricted every new Alembic metadata assertion to the `public` schema. +- Preserved the canonical physical check-constraint name with `op.f(...)`; + hosted schema-contract tests prove it matches the ORM convention. +- Configured the PostgreSQL rollback proof without leaving an implicit + transaction open before the transaction-owning command. + +## Comments deferred + +None. + +## Human decisions needed + +None. + +## Commands rerun + +- Ruff on the changed migration and tests. +- Focused TASK, architecture, and behavior-ownership tests. +- GitHub Backend semantic lanes and Agent Gates on the exact PR head. + +## Remaining risks + +None identified from the CodeRabbit review threads. diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 75481dec6..0f1a1f6cf 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -676,6 +676,10 @@ "group": "lifecycle", "target": "backend/app/modules/reviews/schemas.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/tasks/api/submission_command.py" + }, { "group": "lifecycle", "target": "backend/app/modules/tasks/api/submission_context.py" @@ -712,6 +716,10 @@ "group": "lifecycle", "target": "backend/app/modules/tasks/service.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/tasks/submission_composition.py" + }, { "group": "auth", "target": "backend/app/schemas/auth.py" @@ -817,7 +825,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "2052bde67b61768756b7a2e342abdb96e311d10bc06709d1f61611f298ea2a7a", + "authority_digest": "6e8c9f70a0917f12e28205d7056404a29e1a0611992b7d87b59c54f96e2b5ea4", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 679c436e2..3a3d27677 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,7 +21,7 @@ target_metadata = Base.metadata _BASELINE_REVISION = "0001_v01_baseline" -_CURRENT_HEAD_REVISION = "0002_admission_version" +_CURRENT_HEAD_REVISION = "0003_submission_lineage" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_v01_baseline migration" diff --git a/backend/alembic/versions/0003_submission_artifact_lineage.py b/backend/alembic/versions/0003_submission_artifact_lineage.py new file mode 100644 index 000000000..84399bf34 --- /dev/null +++ b/backend/alembic/versions/0003_submission_artifact_lineage.py @@ -0,0 +1,49 @@ +"""Add canonical admission and artifact lineage to TASK Submissions.""" + +from alembic import op +import sqlalchemy as sa + +revision = "0003_submission_lineage" +down_revision = "0002_admission_version" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("submissions", "package_hash", existing_type=sa.String(128), nullable=True) + op.add_column("submissions", sa.Column("task_assignment_id", sa.String(36))) + op.add_column("submissions", sa.Column("submission_bundle_admission_id", sa.String(36))) + op.add_column("submissions", sa.Column("artifact_binding_id", sa.String(36))) + op.add_column("submissions", sa.Column("artifact_content_id", sa.String(36))) + op.create_foreign_key( + "fk_submissions_task_assignment_id_task_assignments", + "submissions", + "task_assignments", + ["task_assignment_id"], + ["id"], + ) + op.create_index("ix_submissions_task_assignment_id", "submissions", ["task_assignment_id"]) + op.create_index( + "ix_submissions_submission_bundle_admission_id", + "submissions", + ["submission_bundle_admission_id"], + unique=True, + ) + op.create_unique_constraint( + "uq_submissions_artifact_binding_id", "submissions", ["artifact_binding_id"] + ) + op.create_check_constraint( + op.f("ck_submissions_artifact_lineage_shape"), + "submissions", + "(task_assignment_id is null and submission_bundle_admission_id is null " + "and artifact_binding_id is null and artifact_content_id is null) or " + "(task_assignment_id is not null and submission_bundle_admission_id is not null " + "and artifact_binding_id is not null and artifact_content_id is not null)", + ) + op.create_index("ix_submissions_artifact_content_id", "submissions", ["artifact_content_id"]) + + +def downgrade() -> None: + raise RuntimeError( + "Workstream v0.1 migrations cannot be downgraded; recreate the database" + ) diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index b78d1a5eb..40697c057 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -1,11 +1,91 @@ -"""TASK-owned composition adapters.""" +"""TASK-owned composition adapters and transaction roots.""" from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.tasks.api import TaskSubmissionContextPort +from app.modules.artifacts.api import ( + SubmissionAdmissionConsumptionPort, + SubmissionAdmissionConsumptionRequest, +) +from app.modules.tasks.api import ( + SubmissionArtifactAdmissionRequest, + SubmissionArtifactAdmissionResult, + SubmissionCreationAuthorizationPort, + SubmissionCreationAuthorityFacts, + SubmissionCreationPreparationFacts, + SubmissionCreationRequest, + SubmissionCreationResult, + SubmissionCreationUnavailable, + TaskSubmissionContextPort, +) from app.modules.tasks.repository import TaskRepository +from app.modules.tasks.submission_composition import TaskSubmissionCreationService + +__all__ = ( + "DenySubmissionCreationAuthorization", + "TransactionalSubmissionCreationCommand", + "task_submission_context_port", +) def task_submission_context_port(session: AsyncSession) -> TaskSubmissionContextPort: """Bind the public TASK submission-context port to its repository.""" return TaskRepository(session) + + +class DenySubmissionCreationAuthorization: + """Keep the hidden human action unavailable until AUTH activation.""" + + async def authorize(self, facts: SubmissionCreationPreparationFacts) -> None: + del facts + raise SubmissionCreationUnavailable("submission creation is unavailable") + + async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: + del facts + raise SubmissionCreationUnavailable("submission creation is unavailable") + + +class _ArtifactAdmissionAdapter: + def __init__(self, admissions: SubmissionAdmissionConsumptionPort) -> None: + self._admissions = admissions + + async def consume( + self, request: SubmissionArtifactAdmissionRequest + ) -> SubmissionArtifactAdmissionResult: + result = await self._admissions.consume( + SubmissionAdmissionConsumptionRequest( + admission_id=request.admission_id, + submission_id=request.submission_id, + submission_version=request.submission_version, + task_context=request.task_context, + ) + ) + if result.binding_id is None or result.status != "consumed": + raise RuntimeError("admission did not produce a binding") + return SubmissionArtifactAdmissionResult( + binding_id=result.binding_id, content_id=result.content_id + ) + + +class TransactionalSubmissionCreationCommand: + """Open the sole root transaction and delegate sequencing to TASK.""" + + def __init__( + self, + session: AsyncSession, + *, + authorization: SubmissionCreationAuthorizationPort, + admissions: SubmissionAdmissionConsumptionPort, + ) -> None: + self._session = session + self._authorization = authorization + self._admissions = admissions + + async def create(self, request: SubmissionCreationRequest) -> SubmissionCreationResult: + if self._session.in_transaction(): + raise RuntimeError("submission composition requires a transaction-free session") + async with self._session.begin(): + return await TaskSubmissionCreationService( + self._session, + authorization=self._authorization, + admissions=_ArtifactAdmissionAdapter(self._admissions), + ).create(request) diff --git a/backend/app/modules/tasks/api/__init__.py b/backend/app/modules/tasks/api/__init__.py index 894da2f76..9b8c13ec7 100644 --- a/backend/app/modules/tasks/api/__init__.py +++ b/backend/app/modules/tasks/api/__init__.py @@ -11,6 +11,18 @@ TaskSubmissionContextStatus, TaskSubmissionContextUnavailable, ) +from app.modules.tasks.api.submission_command import ( + SubmissionCreationAuthorizationPort, + SubmissionCreationAuthorityFacts, + SubmissionCreationPreparationFacts, + SubmissionCreationCommand, + SubmissionCreationRequest, + SubmissionCreationResult, + SubmissionCreationUnavailable, + SubmissionArtifactAdmissionPort, + SubmissionArtifactAdmissionRequest, + SubmissionArtifactAdmissionResult, +) __all__ = ( "SubmissionPredecessorFacts", @@ -22,4 +34,14 @@ "TaskSubmissionContextRequest", "TaskSubmissionContextStatus", "TaskSubmissionContextUnavailable", + "SubmissionCreationAuthorizationPort", + "SubmissionCreationAuthorityFacts", + "SubmissionCreationPreparationFacts", + "SubmissionCreationCommand", + "SubmissionCreationRequest", + "SubmissionCreationResult", + "SubmissionCreationUnavailable", + "SubmissionArtifactAdmissionPort", + "SubmissionArtifactAdmissionRequest", + "SubmissionArtifactAdmissionResult", ) diff --git a/backend/app/modules/tasks/api/submission_command.py b/backend/app/modules/tasks/api/submission_command.py new file mode 100644 index 000000000..da5ee3354 --- /dev/null +++ b/backend/app/modules/tasks/api/submission_command.py @@ -0,0 +1,108 @@ +"""Public TASK capability for immutable admission-backed Submission creation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +from app.modules.tasks.api.submission_context import TaskSubmissionContextFacts + +class SubmissionCreationUnavailable(RuntimeError): + """Conceal the unavailable hidden Submission creation capability.""" + + +@dataclass(frozen=True, slots=True) +class SubmissionCreationRequest: + """Contributor input and server-selected identities for one Submission.""" + + admission_id: UUID + task_id: UUID + assignment_id: UUID + contributor_id: UUID + predecessor_submission_id: UUID | None + summary: str + contributor_attestation: str + + def __post_init__(self) -> None: + """Reject empty contributor-authored text at the public boundary.""" + if not self.summary.strip() or not self.contributor_attestation.strip(): + raise ValueError("submission text is empty") + + +@dataclass(frozen=True, slots=True) +class SubmissionCreationPreparationFacts: + """TASK selectors checked before protected state is revealed.""" + + task_id: UUID + assignment_id: UUID + contributor_id: UUID + admission_id: UUID + predecessor_submission_id: UUID | None + + +@dataclass(frozen=True, slots=True) +class SubmissionCreationAuthorityFacts(SubmissionCreationPreparationFacts): + """Exact final TASK identity/version required for authority consumption.""" + + submission_id: UUID + submission_version: int + + def __post_init__(self) -> None: + if self.submission_version < 1: + raise ValueError("submission version is invalid") + + +class SubmissionCreationAuthorizationPort(Protocol): + """Authorize and finally consume human Submission authority in one transaction.""" + + async def authorize(self, facts: SubmissionCreationPreparationFacts) -> None: + """Conceal denial before TASK state is locked or revealed.""" + + async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: + """Consume final exact authority after protected facts are known.""" + + +@dataclass(frozen=True, slots=True) +class SubmissionArtifactAdmissionRequest: + """Exact TASK allocation supplied to the artifact admission participant.""" + + admission_id: UUID + submission_id: UUID + submission_version: int + task_context: TaskSubmissionContextFacts + + +@dataclass(frozen=True, slots=True) +class SubmissionArtifactAdmissionResult: + """Artifact identities returned after exact admission consumption.""" + + binding_id: UUID + content_id: UUID + + +class SubmissionArtifactAdmissionPort(Protocol): + """Consume one ready artifact admission in the caller-owned transaction.""" + + async def consume( + self, request: SubmissionArtifactAdmissionRequest + ) -> SubmissionArtifactAdmissionResult: + """Return exact binding/content identity or raise an owner error.""" + + +@dataclass(frozen=True, slots=True) +class SubmissionCreationResult: + """Bounded immutable result of the hidden composed transaction.""" + + submission_id: UUID + submission_version: int + admission_id: UUID + artifact_binding_id: UUID + artifact_content_id: UUID + + +class SubmissionCreationCommand(Protocol): + """Create one immutable Submission through transaction-bound owner ports.""" + + async def create(self, request: SubmissionCreationRequest) -> SubmissionCreationResult: + """Apply one atomic TASK/ART operation without committing independently.""" diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index a0853b3a8..e73743e66 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -458,6 +458,13 @@ class Submission(Base): """, name="post_submit_policy_lock_complete", ), + CheckConstraint( + "(task_assignment_id is null and submission_bundle_admission_id is null " + "and artifact_binding_id is null and artifact_content_id is null) or " + "(task_assignment_id is not null and submission_bundle_admission_id is not null " + "and artifact_binding_id is not null and artifact_content_id is not null)", + name="artifact_lineage_shape", + ), Index( "ix_submissions_locked_source_snapshot", "locked_guide_source_snapshot_id", @@ -477,6 +484,14 @@ class Submission(Base): ) id: Mapped[str] = mapped_column(String(36), primary_key=True) + task_assignment_id: Mapped[str | None] = mapped_column( + ForeignKey("task_assignments.id"), index=True + ) + submission_bundle_admission_id: Mapped[str | None] = mapped_column( + String(36), unique=True, index=True + ) + artifact_binding_id: Mapped[str | None] = mapped_column(String(36), unique=True) + artifact_content_id: Mapped[str | None] = mapped_column(String(36), index=True) task_id: Mapped[str] = mapped_column( ForeignKey("workstream_tasks.id"), nullable=False, index=True ) @@ -490,7 +505,7 @@ class Submission(Base): status: Mapped[str] = mapped_column(String(30), nullable=False, default="submitted", index=True) summary: Mapped[str] = mapped_column(Text, nullable=False) package_uri: Mapped[str | None] = mapped_column(String(1000)) - package_hash: Mapped[str] = mapped_column(String(128), nullable=False) + package_hash: Mapped[str | None] = mapped_column(String(128)) artifact_hash_manifest: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list) worker_attestation: Mapped[str] = mapped_column(Text, nullable=False) locked_guide_version: Mapped[str] = mapped_column(String(50), nullable=False) diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 459ce1b49..c3050d3e4 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -70,6 +70,7 @@ WorkstreamTask, ) from app.modules.tasks.repository import TaskRepository +from app.modules.tasks.submission_composition import build_submission from app.modules.tasks.schemas import ( AssignmentResponse, AuditEventResponse, @@ -728,43 +729,14 @@ async def create_submission( latest_submission = await self._repo.get_latest_submission_for_task(task.id) next_version = 1 if latest_submission is None else latest_submission.version + 1 - submission = Submission( - id=str(uuid4()), - task_id=task.id, - contributor_id=actor.actor_id, - version=next_version, - status="submitted", - summary=payload.summary, - package_uri=payload.package_uri, - package_hash=payload.package_hash, + submission = build_submission( + submission_id=str(uuid4()), task=task, contributor_id=actor.actor_id, + version=next_version, summary=payload.summary, + package_uri=payload.package_uri, package_hash=payload.package_hash, artifact_hash_manifest=[ entry.model_dump(mode="json") for entry in payload.artifact_hash_manifest ], worker_attestation=payload.worker_attestation, - locked_guide_version=task.locked_guide_version, - locked_post_submit_checker_policy_id=task.locked_post_submit_checker_policy_id, - locked_post_submit_checker_policy_version=( - task.locked_post_submit_checker_policy_version - ), - locked_post_submit_checker_policy_hash=task.locked_post_submit_checker_policy_hash, - locked_post_submit_checker_policy_body=task.locked_post_submit_checker_policy_body, - locked_review_policy_id=task.locked_review_policy_id, - locked_review_policy_generation=task.locked_review_policy_generation, - locked_review_policy_hash=task.locked_review_policy_hash, - locked_revision_policy_id=task.locked_revision_policy_id, - locked_revision_policy_generation=task.locked_revision_policy_generation, - locked_revision_policy_hash=task.locked_revision_policy_hash, - locked_payment_policy_version=task.locked_payment_policy_version, - locked_guide_source_snapshot_id=task.locked_guide_source_snapshot_id, - locked_guide_source_snapshot_hash=task.locked_guide_source_snapshot_hash, - locked_effective_project_submission_artifact_policy_id=( - task.locked_effective_project_submission_artifact_policy_id - ), - locked_effective_project_submission_artifact_policy_hash=( - task.locked_effective_project_submission_artifact_policy_hash - ), - locked_pre_submit_checker_policy_id=task.locked_pre_submit_checker_policy_id, - locked_pre_submit_checker_bundle_hash=task.locked_pre_submit_checker_bundle_hash, supersedes_submission_id=None if latest_submission is None else latest_submission.id, evidence_items=[ EvidenceItem( diff --git a/backend/app/modules/tasks/submission_composition.py b/backend/app/modules/tasks/submission_composition.py new file mode 100644 index 000000000..e63fbb3ec --- /dev/null +++ b/backend/app/modules/tasks/submission_composition.py @@ -0,0 +1,157 @@ +"""TASK-owned immutable Submission command for the hidden composed transaction.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.tasks.api import ( + SubmissionArtifactAdmissionPort, + SubmissionArtifactAdmissionRequest, + SubmissionCreationAuthorizationPort, + SubmissionCreationAuthorityFacts, + SubmissionCreationPreparationFacts, + SubmissionCreationRequest, + SubmissionCreationResult, + TaskSubmissionContextRequest, +) +from app.modules.tasks.models import EvidenceItem, Submission +from app.modules.tasks.repository import TaskRepository + + +def build_submission( + *, + submission_id: str, + task: Any, + contributor_id: str, + version: int, + summary: str, + worker_attestation: str, + supersedes_submission_id: str | None, + task_assignment_id: str | None = None, + package_uri: str | None = None, + package_hash: str | None = None, + artifact_hash_manifest: list[dict[str, Any]] | None = None, + evidence_items: Sequence[EvidenceItem] = (), +) -> Submission: + """Build a Submission with one canonical copy of the task policy locks.""" + return Submission( + id=submission_id, + task_id=task.id, + task_assignment_id=task_assignment_id, + contributor_id=contributor_id, + version=version, + status="submitted", + summary=summary, + package_uri=package_uri, + package_hash=package_hash, + artifact_hash_manifest=artifact_hash_manifest or [], + worker_attestation=worker_attestation, + locked_guide_version=task.locked_guide_version, + locked_post_submit_checker_policy_id=task.locked_post_submit_checker_policy_id, + locked_post_submit_checker_policy_version=task.locked_post_submit_checker_policy_version, + locked_post_submit_checker_policy_hash=task.locked_post_submit_checker_policy_hash, + locked_post_submit_checker_policy_body=task.locked_post_submit_checker_policy_body, + locked_review_policy_id=task.locked_review_policy_id, + locked_review_policy_generation=task.locked_review_policy_generation, + locked_review_policy_hash=task.locked_review_policy_hash, + locked_revision_policy_id=task.locked_revision_policy_id, + locked_revision_policy_generation=task.locked_revision_policy_generation, + locked_revision_policy_hash=task.locked_revision_policy_hash, + locked_payment_policy_version=task.locked_payment_policy_version, + locked_guide_source_snapshot_id=task.locked_guide_source_snapshot_id, + locked_guide_source_snapshot_hash=task.locked_guide_source_snapshot_hash, + locked_effective_project_submission_artifact_policy_id=( + task.locked_effective_project_submission_artifact_policy_id + ), + locked_effective_project_submission_artifact_policy_hash=( + task.locked_effective_project_submission_artifact_policy_hash + ), + locked_pre_submit_checker_policy_id=task.locked_pre_submit_checker_policy_id, + locked_pre_submit_checker_bundle_hash=task.locked_pre_submit_checker_bundle_hash, + supersedes_submission_id=supersedes_submission_id, + evidence_items=list(evidence_items), + ) + + +class TaskSubmissionCreationService: + """Sequence TASK and ART owner operations inside a caller-owned transaction.""" + + def __init__( + self, + session: AsyncSession, + *, + authorization: SubmissionCreationAuthorizationPort, + admissions: SubmissionArtifactAdmissionPort, + ) -> None: + self._session = session + self._authorization = authorization + self._admissions = admissions + self._repository = TaskRepository(session) + + async def create(self, request: SubmissionCreationRequest) -> SubmissionCreationResult: + """Create one Submission without opening or committing a transaction.""" + if not self._session.in_transaction() or self._session.in_nested_transaction(): + raise RuntimeError("submission creation requires one root transaction") + preliminary = SubmissionCreationPreparationFacts( + task_id=request.task_id, + assignment_id=request.assignment_id, + contributor_id=request.contributor_id, + admission_id=request.admission_id, + predecessor_submission_id=request.predecessor_submission_id, + ) + await self._authorization.authorize(preliminary) + context = await self._repository.lock_submission_context( + TaskSubmissionContextRequest( + task_id=request.task_id, + assignment_id=request.assignment_id, + contributor_id=request.contributor_id, + predecessor_submission_id=request.predecessor_submission_id, + ) + ) + task = await self._repository.get_task(str(request.task_id)) + if task is None: + raise RuntimeError("locked task disappeared") + version = 1 if context.predecessor is None else context.predecessor.version + 1 + submission_id = uuid4() + submission = build_submission( + submission_id=str(submission_id), task=task, + contributor_id=str(request.contributor_id), version=version, + summary=request.summary, worker_attestation=request.contributor_attestation, + supersedes_submission_id=(str(context.predecessor.submission_id) + if context.predecessor else None), + ) + await self._repository.add_submission(submission) + consumed = await self._admissions.consume( + SubmissionArtifactAdmissionRequest( + admission_id=request.admission_id, + submission_id=submission_id, + submission_version=version, + task_context=context, + ) + ) + submission.submission_bundle_admission_id = str(request.admission_id) + submission.task_assignment_id = str(request.assignment_id) + submission.artifact_binding_id = str(consumed.binding_id) + submission.artifact_content_id = str(consumed.content_id) + await self._session.flush() + final = SubmissionCreationAuthorityFacts( + task_id=preliminary.task_id, + assignment_id=preliminary.assignment_id, + contributor_id=preliminary.contributor_id, + admission_id=preliminary.admission_id, + predecessor_submission_id=preliminary.predecessor_submission_id, + submission_id=submission_id, + submission_version=version, + ) + await self._authorization.consume(final) + return SubmissionCreationResult( + submission_id=submission_id, + submission_version=version, + admission_id=request.admission_id, + artifact_binding_id=consumed.binding_id, + artifact_content_id=consumed.content_id, + ) diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 3684c2d26..6330fb675 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -86,6 +86,12 @@ "backend/app/modules/tasks/api/submission_context.py", } ) +ARCH_02F_SUBMISSION_COMPOSITION_TARGETS = frozenset( + { + "backend/app/modules/tasks/api/submission_command.py", + "backend/app/modules/tasks/submission_composition.py", + } +) POL_03A_CALLABLE_TARGETS = frozenset( { "backend/app/modules/authorization/api/project_guide_compilation.py", @@ -272,6 +278,7 @@ def _validate_additive_partition_transition( | MODULE_PUBLIC_API_FOUNDATION_TARGETS | POL_03A_CALLABLE_TARGETS | AUTH_12I_TARGETS + | ARCH_02F_SUBMISSION_COMPOSITION_TARGETS | V01_BASELINE_ADDED_TARGETS ) expected_additions = (approved_additions & additions) - set(trusted_targets) diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index c27ba8a49..39bf7244a 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -142,6 +142,7 @@ class TestLane: "tests/test_behavior_ownership.py", "tests/test_artifact_admission.py", "tests/test_submission_bundle_admission.py", + "tests/test_submission_composition.py", "tests/test_artifact_bindings.py", "tests/test_artifact_bindings_db.py", "tests/test_pre_submit_evidence_relock.py", diff --git a/backend/tests/authorization/guide_compilation/test_migration_contract.py b/backend/tests/authorization/guide_compilation/test_migration_contract.py index 56497154c..371d5210d 100644 --- a/backend/tests/authorization/guide_compilation/test_migration_contract.py +++ b/backend/tests/authorization/guide_compilation/test_migration_contract.py @@ -60,7 +60,7 @@ def test_current_schema_preserves_exact_compilation_registries( isolated_database_env: str, ) -> None: assert asyncio.run(_registry_state(isolated_database_env)) == ( - "0002_admission_version", + "0003_submission_lineage", 1, 1, 1, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a912a7780..992d2f883 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "43a1295d5938416a317b9917b4eaadb5fe3e540a06e439ca674cdf2ae11d98b0" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "f7e1ea00e7fecdf68b6294a0f57ebdf5deb3b05cbf42058c720b8770fbdd29a0" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/projects/guide_compilation/test_migration_contract.py b/backend/tests/projects/guide_compilation/test_migration_contract.py index ccb2e60ca..e07585f1b 100644 --- a/backend/tests/projects/guide_compilation/test_migration_contract.py +++ b/backend/tests/projects/guide_compilation/test_migration_contract.py @@ -48,7 +48,7 @@ def test_current_schema_preserves_guide_compilation_schema( isolated_database_env: str, ) -> None: assert asyncio.run(_schema_state(isolated_database_env)) == ( - "0002_admission_version", + "0003_submission_lineage", True, 4, 1, diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 7974fa149..b5e8a1d9d 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -20,7 +20,7 @@ ) from scripts.schema_baseline_sql import split_sql_statements -HEAD_REVISION = "0002_admission_version" +HEAD_REVISION = "0003_submission_lineage" BASELINE_REVISION = "0001_v01_baseline" RECREATE_GUIDANCE = "Workstream v0.1 requires a fresh database; recreate this database" pytestmark = pytest.mark.postgres_schema_contract @@ -80,6 +80,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0002_admission_version", BASELINE_REVISION, ] assert revisions[-1].down_revision is None @@ -102,7 +103,7 @@ def test_fresh_database_matches_committed_manifest( assert actual == expected -def test_current_head_installs_consumed_submission_version_contract( +def test_current_head_installs_submission_lineage_contract( isolated_database_env: str, migration_lock ) -> None: config = _alembic_config() @@ -112,7 +113,7 @@ def test_current_head_installs_consumed_submission_version_contract( ) command.upgrade(config, HEAD_REVISION) - async def contract() -> tuple[bool, str]: + async def contract() -> tuple[bool, str, list[str], str, set[str], str]: connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) try: exists = await connection.fetchval( @@ -127,13 +128,63 @@ async def contract() -> tuple[bool, str]: "where c.conname='ck_submission_bundle_admissions_terminal_shape' " "and n.nspname='public' and t.relname='submission_bundle_admissions'" ) - return bool(exists), definition + columns = await connection.fetch( + "select column_name from information_schema.columns " + "where table_schema='public' and table_name='submissions' and " + "column_name=any($1::text[]) order by column_name", + [ + "artifact_binding_id", + "artifact_content_id", + "submission_bundle_admission_id", + "task_assignment_id", + ], + ) + lineage_shape = await connection.fetchval( + "select pg_get_constraintdef(c.oid) from pg_constraint c " + "join pg_class t on t.oid=c.conrelid " + "join pg_namespace n on n.oid=t.relnamespace " + "where c.conname='ck_submissions_artifact_lineage_shape' " + "and t.relname='submissions' and n.nspname='public'" + ) + objects = await connection.fetch( + "select conname as name from pg_constraint c join pg_class t on t.oid=c.conrelid " + "join pg_namespace n on n.oid=t.relnamespace " + "where t.relname='submissions' and n.nspname='public' " + "and conname=any($1::text[]) union all " + "select indexname as name from pg_indexes where tablename='submissions' " + "and schemaname='public' and indexname=any($1::text[])", + ["fk_submissions_task_assignment_id_task_assignments", + "ix_submissions_submission_bundle_admission_id", + "uq_submissions_artifact_binding_id", "ix_submissions_artifact_content_id"], + ) + package_nullable = await connection.fetchval( + "select is_nullable from information_schema.columns where " + "table_schema='public' and table_name='submissions' " + "and column_name='package_hash'" + ) + return (bool(exists), definition, [row["column_name"] for row in columns], + lineage_shape, {row["name"] for row in objects}, package_nullable) finally: await connection.close() - exists, definition = asyncio.run(contract()) + exists, definition, columns, lineage_shape, objects, package_nullable = asyncio.run(contract()) assert exists is True assert "consumed_by_submission_version > 0" in definition + assert columns == [ + "artifact_binding_id", + "artifact_content_id", + "submission_bundle_admission_id", + "task_assignment_id", + ] + assert "task_assignment_id IS NULL" in lineage_shape + assert "artifact_content_id IS NOT NULL" in lineage_shape + assert objects == { + "fk_submissions_task_assignment_id_task_assignments", + "ix_submissions_submission_bundle_admission_id", + "uq_submissions_artifact_binding_id", + "ix_submissions_artifact_content_id", + } + assert package_nullable == "YES" def test_manifest_covers_every_required_object_class() -> None: diff --git a/backend/tests/test_artifact_bindings_db.py b/backend/tests/test_artifact_bindings_db.py index 9136b05a0..8cacace80 100644 --- a/backend/tests/test_artifact_bindings_db.py +++ b/backend/tests/test_artifact_bindings_db.py @@ -21,6 +21,10 @@ SubmissionAdmissionConsumptionService, ) from app.modules.artifacts.api import SubmissionAdmissionConsumptionError +from app.adapters.tasks import TransactionalSubmissionCreationCommand +from app.modules.tasks.api import SubmissionCreationRequest, SubmissionCreationUnavailable +from app.modules.tasks.models import Submission +from app.modules.tasks.repository import TaskRepository from test_artifact_bindings import _Allow, _lineage, _request @@ -29,6 +33,7 @@ PreSubmitEvidenceSet.__table__, SubmissionBundleAdmission.__table__, ArtifactBinding.__table__, + Submission.__table__, ) @@ -118,18 +123,86 @@ async def _seed(session, schema: str, request) -> None: ) await session.execute( SubmissionBundleAdmission.__table__.insert().values( - **vars(admission), - durable_intent_id=str(uuid4()), - put_attempt_id=str(uuid4()), - verified_replica_id=str(uuid4()), - verification_receipt_id=str(uuid4()), - put_operation_receipt_id=str(uuid4()), - put_observation_receipt_id=None, + **vars(admission), durable_intent_id=str(uuid4()), put_attempt_id=str(uuid4()), + verified_replica_id=str(uuid4()), verification_receipt_id=str(uuid4()), + put_operation_receipt_id=str(uuid4()), put_observation_receipt_id=None, ready_at=text("now()"), ) ) +class _FinalDeny: + async def authorize(self, facts) -> None: + del facts + + async def consume(self, facts) -> None: + del facts + raise SubmissionCreationUnavailable("submission creation is unavailable") + + +@pytest.mark.asyncio +async def test_composed_final_denial_rolls_back_task_and_art_rows( + isolated_database_env: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + art_request = _request() + context = art_request.task_context + task = type("LockedTask", (), { + "id": str(context.task_id), + "locked_guide_version": "1", + "locked_post_submit_checker_policy_id": str(uuid4()), + "locked_post_submit_checker_policy_version": "1", + "locked_post_submit_checker_policy_hash": "sha256:" + "4" * 64, + "locked_post_submit_checker_policy_body": {}, + "locked_review_policy_id": str(uuid4()), "locked_review_policy_generation": 1, + "locked_review_policy_hash": "sha256:" + "5" * 64, + "locked_revision_policy_id": str(uuid4()), "locked_revision_policy_generation": 1, + "locked_revision_policy_hash": "sha256:" + "6" * 64, + "locked_payment_policy_version": "1", + "locked_guide_source_snapshot_id": str(context.locked_project_context.source_snapshot_id), + "locked_guide_source_snapshot_hash": context.locked_project_context.source_snapshot_hash, + "locked_effective_project_submission_artifact_policy_id": str(context.locked_project_context.effective_policy_id), + "locked_effective_project_submission_artifact_policy_hash": context.locked_project_context.effective_policy_hash, + "locked_pre_submit_checker_policy_id": str(context.locked_project_context.pre_submit_policy_id), + "locked_pre_submit_checker_bundle_hash": context.locked_project_context.pre_submit_policy_bundle_hash, + })() + + async def lock_context(self, request): + del self, request + return context + + async def get_task(self, task_id, **kwargs): + del self, task_id, kwargs + return task + + monkeypatch.setattr(TaskRepository, "lock_submission_context", lock_context) + monkeypatch.setattr(TaskRepository, "get_task", get_task) + async with _isolated_binding_schema(isolated_database_env) as (schema, factory): + async with factory.begin() as seed: + await _seed(seed, schema, art_request) + request = SubmissionCreationRequest( + admission_id=art_request.admission_id, task_id=context.task_id, + assignment_id=context.assignment_id, contributor_id=context.contributor_id, + predecessor_submission_id=None, summary="summary", + contributor_attestation="attestation", + ) + async with factory() as session: + await session.execute(text(f'set search_path to "{schema}"')) + await session.commit() + command = TransactionalSubmissionCreationCommand( + session, authorization=_FinalDeny(), + admissions=SubmissionAdmissionConsumptionService(session, _Allow()), + ) + with pytest.raises(SubmissionCreationUnavailable): + await command.create(request) + async with factory() as session: + await _set_schema(session, schema) + assert await session.scalar(text("select count(*) from submissions")) == 0 + assert await session.scalar(text("select count(*) from artifact_bindings")) == 0 + status = await session.scalar( + text("select status from submission_bundle_admissions where id=:id"), + {"id": str(request.admission_id)}, + ) + assert status == "ready" @pytest.mark.asyncio async def test_postgresql_consumption_is_concurrent_and_rollback_safe( isolated_database_env: str, diff --git a/backend/tests/test_submission_composition.py b/backend/tests/test_submission_composition.py new file mode 100644 index 000000000..2d89efab4 --- /dev/null +++ b/backend/tests/test_submission_composition.py @@ -0,0 +1,182 @@ +"""Focused behavior proof for hidden admission-backed Submission composition.""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.modules.tasks.api import ( + SubmissionPredecessorFacts, + SubmissionArtifactAdmissionResult, + SubmissionCreationRequest, + SubmissionCreationUnavailable, + TaskLockedProjectContextReferences, + TaskSubmissionContextFacts, +) +from app.modules.tasks.submission_composition import TaskSubmissionCreationService + + +class _Session: + def in_transaction(self): + return True + + def in_nested_transaction(self): + return False + + async def flush(self): + return None + + +def _request(): + return SubmissionCreationRequest( + admission_id=uuid4(), task_id=uuid4(), assignment_id=uuid4(), + contributor_id=uuid4(), predecessor_submission_id=None, + summary="summary", contributor_attestation="attestation", + ) + + +def _context(request): + return TaskSubmissionContextFacts( + task_id=request.task_id, assignment_id=request.assignment_id, + contributor_id=request.contributor_id, status="in_progress", kind="initial", + predecessor=None, + locked_project_context=TaskLockedProjectContextReferences( + project_id=uuid4(), guide_version="1", source_snapshot_id=uuid4(), + source_snapshot_hash="sha256:" + "1" * 64, effective_policy_id=uuid4(), + effective_policy_hash="sha256:" + "2" * 64, + pre_submit_policy_id=uuid4(), + pre_submit_policy_bundle_hash="sha256:" + "3" * 64, + ), + ) + + +def _task(): + values = { + "id": str(uuid4()), + "locked_guide_version": "1", "locked_post_submit_checker_policy_id": str(uuid4()), + "locked_post_submit_checker_policy_version": "1", + "locked_post_submit_checker_policy_hash": "sha256:" + "4" * 64, + "locked_post_submit_checker_policy_body": {}, "locked_review_policy_id": str(uuid4()), + "locked_review_policy_generation": 1, + "locked_review_policy_hash": "sha256:" + "5" * 64, + "locked_revision_policy_id": str(uuid4()), "locked_revision_policy_generation": 1, + "locked_revision_policy_hash": "sha256:" + "6" * 64, + "locked_payment_policy_version": "1", "locked_guide_source_snapshot_id": str(uuid4()), + "locked_guide_source_snapshot_hash": "sha256:" + "7" * 64, + "locked_effective_project_submission_artifact_policy_id": str(uuid4()), + "locked_effective_project_submission_artifact_policy_hash": "sha256:" + "8" * 64, + "locked_pre_submit_checker_policy_id": str(uuid4()), + "locked_pre_submit_checker_bundle_hash": "sha256:" + "9" * 64, + } + return SimpleNamespace(**values) + + +@pytest.mark.asyncio +async def test_command_orders_authority_task_art_persistence_and_final_consumption(): + request = _request() + events = [] + + class Authority: + async def authorize(self, facts): events.append(("authorize", facts.task_id)) + async def consume(self, facts): events.append(("final", facts.submission_version)) + + class Admissions: + async def consume(self, value): + events.append(("art", value.submission_version)) + return SubmissionArtifactAdmissionResult(binding_id=uuid4(), content_id=uuid4()) + + service = TaskSubmissionCreationService(_Session(), authorization=Authority(), admissions=Admissions()) + + class Repository: + async def lock_submission_context(self, value): + events.append(("task", value.task_id)) + return _context(request) + async def get_task(self, task_id): return _task() + async def add_submission(self, submission): events.append(("persist", submission.version)) + + service._repository = Repository() + result = await service.create(request) + assert [event[0] for event in events] == ["authorize", "task", "persist", "art", "final"] + assert result.submission_version == 1 + + +@pytest.mark.asyncio +async def test_denial_precedes_task_lock_and_all_mutation(): + class Authority: + async def authorize(self, facts): raise SubmissionCreationUnavailable + async def consume(self, facts): raise AssertionError("unreachable") + + service = TaskSubmissionCreationService(_Session(), authorization=Authority(), admissions=None) + service._repository = SimpleNamespace( + lock_submission_context=lambda value: pytest.fail("TASK state was revealed") + ) + with pytest.raises(SubmissionCreationUnavailable): + await service.create(_request()) + + +@pytest.mark.asyncio +async def test_final_authority_failure_remains_inside_caller_transaction(): + request = _request() + + class Authority: + async def authorize(self, facts): pass + async def consume(self, facts): raise SubmissionCreationUnavailable + + class Admissions: + async def consume(self, value): + return SubmissionArtifactAdmissionResult(binding_id=uuid4(), content_id=uuid4()) + + service = TaskSubmissionCreationService(_Session(), authorization=Authority(), admissions=Admissions()) + persisted = [] + + class Repository: + async def lock_submission_context(self, value): return _context(request) + async def get_task(self, task_id): return _task() + async def add_submission(self, submission): persisted.append(submission) + + service._repository = Repository() + with pytest.raises(SubmissionCreationUnavailable): + await service.create(request) + assert len(persisted) == 1 + + +@pytest.mark.asyncio +async def test_revision_increments_and_binds_the_exact_predecessor(): + predecessor = SubmissionPredecessorFacts(submission_id=uuid4(), version=1) + initial = _request() + request = SubmissionCreationRequest( + admission_id=initial.admission_id, task_id=initial.task_id, + assignment_id=initial.assignment_id, contributor_id=initial.contributor_id, + predecessor_submission_id=predecessor.submission_id, + summary=initial.summary, contributor_attestation=initial.contributor_attestation, + ) + context = _context(request) + context = TaskSubmissionContextFacts( + task_id=context.task_id, assignment_id=context.assignment_id, + contributor_id=context.contributor_id, status="needs_revision", kind="revision", + predecessor=predecessor, locked_project_context=context.locked_project_context, + ) + seen = {} + + class Authority: + async def authorize(self, facts): pass + async def consume(self, facts): seen.update(final=facts) + + class Admissions: + async def consume(self, value): + seen["art"] = value + return SubmissionArtifactAdmissionResult(binding_id=uuid4(), content_id=uuid4()) + + service = TaskSubmissionCreationService(_Session(), authorization=Authority(), admissions=Admissions()) + + class Repository: + async def lock_submission_context(self, value): return context + async def get_task(self, task_id): return _task() + async def add_submission(self, submission): seen.update(submission=submission) + + service._repository = Repository() + result = await service.create(request) + assert result.submission_version == 2 + assert seen["submission"].supersedes_submission_id == str(predecessor.submission_id) + assert seen["art"].submission_version == 2 + assert seen["final"].predecessor_submission_id == predecessor.submission_id diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 64c650d10..ad304ee1c 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1331,12 +1331,13 @@ Fields: - `version` - `status` - `summary` -- `submission_bundle_admission_id` (target canonical intake identity after ART-05) -- `artifact_binding_id` (target canonical byte binding after ART-05) +- `submission_bundle_admission_id` (hidden canonical intake identity from WS-ARCH-001-02F; public cutover remains 02I) +- `artifact_binding_id` (hidden canonical byte binding from WS-ARCH-001-02F; public cutover remains 02I) +- `artifact_content_id` (hidden canonical immutable ART content identity from WS-ARCH-001-02F; public cutover remains 02I) - `submission_bundle_manifest_id` (target server-generated manifest identity) - `pre_submit_evidence_set_id` (target checker evidence identity) - `package_uri` (legacy caller transport removed by WS-ARCH-001-02I, which implements the superseded WS-ART-001-05B cutover) -- `package_hash` (legacy caller input removed by WS-ARCH-001-02I; never canonical) +- `package_hash` (nullable legacy caller input removed by WS-ARCH-001-02I; never canonical) - `artifact_hash` (legacy transitional column replaced by exact binding/content identity and removed separately after all readers cut over) - `artifact_hash_manifest` (legacy caller manifest removed by WS-ARCH-001-02I) - `contributor_attestation`