From 28281e036fcf204e7fa1157cff8ad00d6c9b9157 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 11:40:50 +0100 Subject: [PATCH 1/8] feat(tasks): compose admission-backed submissions --- .agent-loop/CURRENT_STATE.md | 2 +- .../CHUNK_MAP.md | 4 +- .../STATUS.md | 10 +- ...RCH-001-02F-task-submission-composition.md | 44 ++++-- .../WS-ARCH-001-02F-transaction-manifest.md | 49 +++++++ backend/alembic/env.py | 2 +- .../0003_submission_artifact_lineage.py | 41 ++++++ backend/app/adapters/tasks/__init__.py | 67 ++++++++- .../adapters/tasks/submission_composition.py | 20 +++ backend/app/modules/tasks/api/__init__.py | 20 +++ .../modules/tasks/api/submission_command.py | 98 +++++++++++++ backend/app/modules/tasks/models.py | 10 +- .../modules/tasks/submission_composition.py | 127 ++++++++++++++++ .../test_migration_contract.py | 2 +- .../test_migration_contract.py | 2 +- backend/tests/test_alembic.py | 27 +++- backend/tests/test_submission_composition.py | 135 ++++++++++++++++++ docs/architecture_data_model.md | 2 +- 18 files changed, 636 insertions(+), 26 deletions(-) create mode 100644 .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02F-transaction-manifest.md create mode 100644 backend/alembic/versions/0003_submission_artifact_lineage.py create mode 100644 backend/app/adapters/tasks/submission_composition.py create mode 100644 backend/app/modules/tasks/api/submission_command.py create mode 100644 backend/app/modules/tasks/submission_composition.py create mode 100644 backend/tests/test_submission_composition.py diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index ba30f61df..291c90f51 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..2a7755255 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,7 +42,9 @@ 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/submission_composition.py +backend/app/adapters/tasks/__init__.py backend/app/main.py backend/alembic/versions/.py backend/tests/test_tasks.py @@ -71,11 +74,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,6 +93,23 @@ 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. Call the ART admission-consumption public port with that exact id, version, + and TASK context; ART locks its lineage and consumes fixed binding authority. +6. Persist and flush the TASK-owned Submission with the exact predecessor and + ART content/binding references required by the command contract. +7. 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 @@ -112,3 +136,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..f0d670bab --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02F-transaction-manifest.md @@ -0,0 +1,49 @@ +# 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. ART consumes the exact admission and fixed binding authority through its + public port, locking ART lineage and binding scope. +6. TASK inserts and flushes the immutable Submission with admission, binding, + content, assignment, predecessor, and locked policy references. +7. TASK consumes final human authority using the allocated identity/version. +8. 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/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..7f03b7fed --- /dev/null +++ b/backend/alembic/versions/0003_submission_artifact_lineage.py @@ -0,0 +1,41 @@ +"""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_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..340718f71 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -2,10 +2,75 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.tasks.api import TaskSubmissionContextPort +from app.modules.artifacts.api import SubmissionAdmissionConsumptionPort +from app.modules.tasks.api import ( + SubmissionArtifactAdmissionRequest, + SubmissionArtifactAdmissionResult, + SubmissionCreationAuthorizationPort, + SubmissionCreationRequest, + SubmissionCreationResult, + TaskSubmissionContextPort, +) from app.modules.tasks.repository import TaskRepository +from app.modules.tasks.submission_composition import TaskSubmissionCreationService +from app.adapters.tasks.submission_composition import ( + DenySubmissionCreationAuthorization, +) + +__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 TransactionalSubmissionCreationCommand: + """Open the sole root transaction and delegate lifecycle 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: + """Commit once only after TASK and ART participants succeed.""" + if self._session.in_transaction(): + raise RuntimeError("submission composition requires a transaction-free session") + async with self._session.begin(): + class ArtifactAdmissionAdapter: + async def consume( + _self, request: SubmissionArtifactAdmissionRequest + ) -> SubmissionArtifactAdmissionResult: + from app.modules.artifacts.api import SubmissionAdmissionConsumptionRequest + + 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, + ) + + return await TaskSubmissionCreationService( + self._session, + authorization=self._authorization, + admissions=ArtifactAdmissionAdapter(), + ).create(request) diff --git a/backend/app/adapters/tasks/submission_composition.py b/backend/app/adapters/tasks/submission_composition.py new file mode 100644 index 000000000..4daa57e7e --- /dev/null +++ b/backend/app/adapters/tasks/submission_composition.py @@ -0,0 +1,20 @@ +"""Hidden composition for the TASK-owned admission-backed Submission command.""" + +from __future__ import annotations + +from app.modules.tasks.api import ( + SubmissionCreationAuthorityFacts, + SubmissionCreationUnavailable, +) + + +class DenySubmissionCreationAuthorization: + """Keep the hidden human action unavailable until AUTH activation.""" + + async def authorize(self, facts: SubmissionCreationAuthorityFacts) -> None: + del facts + raise SubmissionCreationUnavailable("submission creation is unavailable") + + async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: + del facts + raise SubmissionCreationUnavailable("submission creation is unavailable") diff --git a/backend/app/modules/tasks/api/__init__.py b/backend/app/modules/tasks/api/__init__.py index 894da2f76..6e2d54104 100644 --- a/backend/app/modules/tasks/api/__init__.py +++ b/backend/app/modules/tasks/api/__init__.py @@ -11,6 +11,17 @@ TaskSubmissionContextStatus, TaskSubmissionContextUnavailable, ) +from app.modules.tasks.api.submission_command import ( + SubmissionCreationAuthorizationPort, + SubmissionCreationAuthorityFacts, + SubmissionCreationCommand, + SubmissionCreationRequest, + SubmissionCreationResult, + SubmissionCreationUnavailable, + SubmissionArtifactAdmissionPort, + SubmissionArtifactAdmissionRequest, + SubmissionArtifactAdmissionResult, +) __all__ = ( "SubmissionPredecessorFacts", @@ -22,4 +33,13 @@ "TaskSubmissionContextRequest", "TaskSubmissionContextStatus", "TaskSubmissionContextUnavailable", + "SubmissionCreationAuthorizationPort", + "SubmissionCreationAuthorityFacts", + "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..f679c0732 --- /dev/null +++ b/backend/app/modules/tasks/api/submission_command.py @@ -0,0 +1,98 @@ +"""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 SubmissionCreationAuthorityFacts: + """Exact TASK-owned facts consumed around protected mutation.""" + + task_id: UUID + assignment_id: UUID + contributor_id: UUID + admission_id: UUID + predecessor_submission_id: UUID | None + submission_id: UUID | None = None + submission_version: int | None = None + + +class SubmissionCreationAuthorizationPort(Protocol): + """Authorize and finally consume human Submission authority in one transaction.""" + + async def authorize(self, facts: SubmissionCreationAuthorityFacts) -> 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..f59eec03d 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -477,6 +477,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 +498,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/submission_composition.py b/backend/app/modules/tasks/submission_composition.py new file mode 100644 index 000000000..a58dd6a31 --- /dev/null +++ b/backend/app/modules/tasks/submission_composition.py @@ -0,0 +1,127 @@ +"""TASK-owned immutable Submission command for the hidden composed transaction.""" + +from __future__ import annotations + +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.tasks.api import ( + SubmissionArtifactAdmissionPort, + SubmissionArtifactAdmissionRequest, + SubmissionCreationAuthorizationPort, + SubmissionCreationAuthorityFacts, + SubmissionCreationRequest, + SubmissionCreationResult, + TaskSubmissionContextRequest, +) +from app.modules.tasks.models import Submission +from app.modules.tasks.repository import TaskRepository + + +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 = SubmissionCreationAuthorityFacts( + 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() + consumed = await self._admissions.consume( + SubmissionArtifactAdmissionRequest( + admission_id=request.admission_id, + submission_id=submission_id, + submission_version=version, + task_context=context, + ) + ) + submission = Submission( + id=str(submission_id), + task_id=str(request.task_id), + task_assignment_id=str(request.assignment_id), + contributor_id=str(request.contributor_id), + version=version, + status="submitted", + summary=request.summary, + package_uri=None, + package_hash=None, + artifact_hash_manifest=[], + worker_attestation=request.contributor_attestation, + submission_bundle_admission_id=str(request.admission_id), + artifact_binding_id=str(consumed.binding_id), + artifact_content_id=str(consumed.content_id), + 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=( + str(context.predecessor.submission_id) if context.predecessor else None + ), + ) + await self._repository.add_submission(submission) + final = SubmissionCreationAuthorityFacts( + **{name: getattr(preliminary, name) for name in ( + "task_id", "assignment_id", "contributor_id", "admission_id", + "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/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/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..447225ffd 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 @@ -102,7 +102,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 +112,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]]: connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) try: exists = await connection.fetchval( @@ -127,13 +127,30 @@ 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", + ], + ) + return bool(exists), definition, [row["column_name"] for row in columns] finally: await connection.close() - exists, definition = asyncio.run(contract()) + exists, definition, columns = 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", + ] def test_manifest_covers_every_required_object_class() -> None: diff --git a/backend/tests/test_submission_composition.py b/backend/tests/test_submission_composition.py new file mode 100644 index 000000000..f3ff39d47 --- /dev/null +++ b/backend/tests/test_submission_composition.py @@ -0,0 +1,135 @@ +"""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 ( + 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 + + +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 = { + "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.submission_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", "art", "persist", "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 diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 64c650d10..6f02065da 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1336,7 +1336,7 @@ Fields: - `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` From 4de33d9b13a27ce12ddcc650a13eee321c9a1c8a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 12:00:22 +0100 Subject: [PATCH 2/8] fix(tasks): harden submission composition contracts --- ...RCH-001-02F-task-submission-composition.md | 22 +-- .../WS-ARCH-001-02F-transaction-manifest.md | 9 +- .ci/behavior-ownership/partition.v1.json | 14 +- .../private-edge-debt.v1.json | 6 + .../0003_submission_artifact_lineage.py | 8 ++ backend/app/adapters/tasks/__init__.py | 59 +------- .../adapters/tasks/submission_composition.py | 63 ++++++++- backend/app/modules/tasks/api/__init__.py | 2 + .../modules/tasks/api/submission_command.py | 20 ++- backend/app/modules/tasks/models.py | 7 + backend/app/modules/tasks/service.py | 38 +----- .../modules/tasks/submission_composition.py | 126 +++++++++++------- backend/scripts/behavior_ownership.py | 8 ++ backend/scripts/run_test_lanes.py | 1 + backend/tests/test_alembic.py | 15 ++- backend/tests/test_submission_composition.py | 8 +- docs/architecture_data_model.md | 4 +- 17 files changed, 246 insertions(+), 164 deletions(-) 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 2a7755255..8a4b3e044 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 @@ -47,10 +47,14 @@ backend/app/adapters/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/scripts/run_test_lanes.py +backend/scripts/behavior_ownership.py backend/tests/test_alembic.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/** @@ -100,11 +104,13 @@ contribution dispatch; compatibility facade. 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. Call the ART admission-consumption public port with that exact id, version, +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. -6. Persist and flush the TASK-owned Submission with the exact predecessor and - ART content/binding references required by the command contract. -7. Consume/record any final TASK-owned authority evidence inside the same root +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 @@ -113,8 +119,8 @@ 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 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 index f0d670bab..ca4716e5c 100644 --- 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 @@ -13,12 +13,13 @@ and production authorization remains deny-only. 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. ART consumes the exact admission and fixed binding authority through its +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. -6. TASK inserts and flushes the immutable Submission with admission, binding, +7. TASK completes and flushes the immutable Submission with admission, binding, content, assignment, predecessor, and locked policy references. -7. TASK consumes final human authority using the allocated identity/version. -8. The adapter commits once; every exception or cancellation rolls back all +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 diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 75481dec6..be5f15946 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -28,6 +28,10 @@ "group": "lifecycle", "target": "backend/app/adapters/project_agents/openai_agent_sdk.py" }, + { + "group": "lifecycle", + "target": "backend/app/adapters/tasks/submission_composition.py" + }, { "group": "shared", "target": "backend/app/api/deps/api_controls.py" @@ -676,6 +680,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 +720,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 +829,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "2052bde67b61768756b7a2e342abdb96e311d10bc06709d1f61611f298ea2a7a", + "authority_digest": "4a06d3ea196d5aa9972b2c07019401f5fd5a8f44049745024359660e11a92b08", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/module-boundaries/private-edge-debt.v1.json b/.ci/module-boundaries/private-edge-debt.v1.json index 1652cc348..1a62e3f58 100644 --- a/.ci/module-boundaries/private-edge-debt.v1.json +++ b/.ci/module-boundaries/private-edge-debt.v1.json @@ -1,6 +1,12 @@ { "schema_version": 1, "edges": [ + { + "source_file": "backend/app/adapters/tasks/submission_composition.py", + "target_module": "tasks", + "imported_private_path": "app.modules.tasks.submission_composition", + "repair_owner": "WS-ARCH-001-03" + }, { "source_file": "backend/app/adapters/artifacts/__init__.py", "target_module": "actors", diff --git a/backend/alembic/versions/0003_submission_artifact_lineage.py b/backend/alembic/versions/0003_submission_artifact_lineage.py index 7f03b7fed..8bf6aebb9 100644 --- a/backend/alembic/versions/0003_submission_artifact_lineage.py +++ b/backend/alembic/versions/0003_submission_artifact_lineage.py @@ -32,6 +32,14 @@ def upgrade() -> None: op.create_unique_constraint( "uq_submissions_artifact_binding_id", "submissions", ["artifact_binding_id"] ) + op.create_check_constraint( + "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"]) diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index 340718f71..7747bd4a2 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -2,19 +2,11 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.artifacts.api import SubmissionAdmissionConsumptionPort -from app.modules.tasks.api import ( - SubmissionArtifactAdmissionRequest, - SubmissionArtifactAdmissionResult, - SubmissionCreationAuthorizationPort, - SubmissionCreationRequest, - SubmissionCreationResult, - TaskSubmissionContextPort, -) +from app.modules.tasks.api import TaskSubmissionContextPort from app.modules.tasks.repository import TaskRepository -from app.modules.tasks.submission_composition import TaskSubmissionCreationService from app.adapters.tasks.submission_composition import ( DenySubmissionCreationAuthorization, + TransactionalSubmissionCreationCommand, ) __all__ = ( @@ -27,50 +19,3 @@ def task_submission_context_port(session: AsyncSession) -> TaskSubmissionContextPort: """Bind the public TASK submission-context port to its repository.""" return TaskRepository(session) - - -class TransactionalSubmissionCreationCommand: - """Open the sole root transaction and delegate lifecycle 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: - """Commit once only after TASK and ART participants succeed.""" - if self._session.in_transaction(): - raise RuntimeError("submission composition requires a transaction-free session") - async with self._session.begin(): - class ArtifactAdmissionAdapter: - async def consume( - _self, request: SubmissionArtifactAdmissionRequest - ) -> SubmissionArtifactAdmissionResult: - from app.modules.artifacts.api import SubmissionAdmissionConsumptionRequest - - 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, - ) - - return await TaskSubmissionCreationService( - self._session, - authorization=self._authorization, - admissions=ArtifactAdmissionAdapter(), - ).create(request) diff --git a/backend/app/adapters/tasks/submission_composition.py b/backend/app/adapters/tasks/submission_composition.py index 4daa57e7e..20a42a3e2 100644 --- a/backend/app/adapters/tasks/submission_composition.py +++ b/backend/app/adapters/tasks/submission_composition.py @@ -2,19 +2,80 @@ from __future__ import annotations +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.artifacts.api import ( + SubmissionAdmissionConsumptionPort, + SubmissionAdmissionConsumptionRequest, +) from app.modules.tasks.api import ( + SubmissionArtifactAdmissionRequest, + SubmissionArtifactAdmissionResult, + SubmissionCreationAuthorizationPort, SubmissionCreationAuthorityFacts, + SubmissionCreationPreparationFacts, + SubmissionCreationRequest, + SubmissionCreationResult, SubmissionCreationUnavailable, ) +from app.modules.tasks.submission_composition import TaskSubmissionCreationService class DenySubmissionCreationAuthorization: """Keep the hidden human action unavailable until AUTH activation.""" - async def authorize(self, facts: SubmissionCreationAuthorityFacts) -> None: + async def authorize(self, facts: SubmissionCreationPreparationFacts) -> 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) + async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: del facts raise SubmissionCreationUnavailable("submission creation is unavailable") diff --git a/backend/app/modules/tasks/api/__init__.py b/backend/app/modules/tasks/api/__init__.py index 6e2d54104..9b8c13ec7 100644 --- a/backend/app/modules/tasks/api/__init__.py +++ b/backend/app/modules/tasks/api/__init__.py @@ -14,6 +14,7 @@ from app.modules.tasks.api.submission_command import ( SubmissionCreationAuthorizationPort, SubmissionCreationAuthorityFacts, + SubmissionCreationPreparationFacts, SubmissionCreationCommand, SubmissionCreationRequest, SubmissionCreationResult, @@ -35,6 +36,7 @@ "TaskSubmissionContextUnavailable", "SubmissionCreationAuthorizationPort", "SubmissionCreationAuthorityFacts", + "SubmissionCreationPreparationFacts", "SubmissionCreationCommand", "SubmissionCreationRequest", "SubmissionCreationResult", diff --git a/backend/app/modules/tasks/api/submission_command.py b/backend/app/modules/tasks/api/submission_command.py index f679c0732..da5ee3354 100644 --- a/backend/app/modules/tasks/api/submission_command.py +++ b/backend/app/modules/tasks/api/submission_command.py @@ -31,22 +31,32 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) -class SubmissionCreationAuthorityFacts: - """Exact TASK-owned facts consumed around protected mutation.""" +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 - submission_id: UUID | None = None - submission_version: int | None = 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: SubmissionCreationAuthorityFacts) -> None: + async def authorize(self, facts: SubmissionCreationPreparationFacts) -> None: """Conceal denial before TASK state is locked or revealed.""" async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index f59eec03d..a709a68c8 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -187,6 +187,13 @@ class WorkstreamTask(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_workstream_tasks_locked_source_snapshot", "locked_guide_source_snapshot_id", 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 index a58dd6a31..69771bacd 100644 --- a/backend/app/modules/tasks/submission_composition.py +++ b/backend/app/modules/tasks/submission_composition.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence +from typing import Any from uuid import uuid4 from sqlalchemy.ext.asyncio import AsyncSession @@ -11,14 +13,70 @@ SubmissionArtifactAdmissionRequest, SubmissionCreationAuthorizationPort, SubmissionCreationAuthorityFacts, + SubmissionCreationPreparationFacts, SubmissionCreationRequest, SubmissionCreationResult, TaskSubmissionContextRequest, ) -from app.modules.tasks.models import Submission +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.""" @@ -38,7 +96,7 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation """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 = SubmissionCreationAuthorityFacts( + preliminary = SubmissionCreationPreparationFacts( task_id=request.task_id, assignment_id=request.assignment_id, contributor_id=request.contributor_id, @@ -59,6 +117,15 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation 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, + task_assignment_id=str(request.assignment_id), + 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, @@ -67,53 +134,16 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation task_context=context, ) ) - submission = Submission( - id=str(submission_id), - task_id=str(request.task_id), - task_assignment_id=str(request.assignment_id), - contributor_id=str(request.contributor_id), - version=version, - status="submitted", - summary=request.summary, - package_uri=None, - package_hash=None, - artifact_hash_manifest=[], - worker_attestation=request.contributor_attestation, - submission_bundle_admission_id=str(request.admission_id), - artifact_binding_id=str(consumed.binding_id), - artifact_content_id=str(consumed.content_id), - 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=( - str(context.predecessor.submission_id) if context.predecessor else None - ), - ) - await self._repository.add_submission(submission) + submission.submission_bundle_admission_id = str(request.admission_id) + submission.artifact_binding_id = str(consumed.binding_id) + submission.artifact_content_id = str(consumed.content_id) + await self._session.flush() final = SubmissionCreationAuthorityFacts( - **{name: getattr(preliminary, name) for name in ( - "task_id", "assignment_id", "contributor_id", "admission_id", - "predecessor_submission_id", - )}, + 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, ) diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 3684c2d26..81662e0ff 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -86,6 +86,13 @@ "backend/app/modules/tasks/api/submission_context.py", } ) +ARCH_02F_SUBMISSION_COMPOSITION_TARGETS = frozenset( + { + "backend/app/adapters/tasks/submission_composition.py", + "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 +279,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/test_alembic.py b/backend/tests/test_alembic.py index 447225ffd..267dbc04f 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -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 @@ -112,7 +113,7 @@ def test_current_head_installs_submission_lineage_contract( ) command.upgrade(config, HEAD_REVISION) - async def contract() -> tuple[bool, str, list[str]]: + async def contract() -> tuple[bool, str, list[str], str]: connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) try: exists = await connection.fetchval( @@ -138,11 +139,17 @@ async def contract() -> tuple[bool, str, list[str]]: "task_assignment_id", ], ) - return bool(exists), definition, [row["column_name"] for row in columns] + lineage_shape = await connection.fetchval( + "select pg_get_constraintdef(c.oid) from pg_constraint c " + "join pg_class t on t.oid=c.conrelid " + "where c.conname='ck_submissions_artifact_lineage_shape' " + "and t.relname='submissions'" + ) + return bool(exists), definition, [row["column_name"] for row in columns], lineage_shape finally: await connection.close() - exists, definition, columns = asyncio.run(contract()) + exists, definition, columns, lineage_shape = asyncio.run(contract()) assert exists is True assert "consumed_by_submission_version > 0" in definition assert columns == [ @@ -151,6 +158,8 @@ async def contract() -> tuple[bool, str, list[str]]: "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 def test_manifest_covers_every_required_object_class() -> None: diff --git a/backend/tests/test_submission_composition.py b/backend/tests/test_submission_composition.py index f3ff39d47..202012561 100644 --- a/backend/tests/test_submission_composition.py +++ b/backend/tests/test_submission_composition.py @@ -22,6 +22,9 @@ def in_transaction(self): def in_nested_transaction(self): return False + async def flush(self): + return None + def _request(): return SubmissionCreationRequest( @@ -48,6 +51,7 @@ def _context(request): 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, @@ -72,7 +76,7 @@ async def test_command_orders_authority_task_art_persistence_and_final_consumpti events = [] class Authority: - async def authorize(self, facts): events.append(("authorize", facts.submission_id)) + async def authorize(self, facts): events.append(("authorize", facts.task_id)) async def consume(self, facts): events.append(("final", facts.submission_version)) class Admissions: @@ -91,7 +95,7 @@ async def add_submission(self, submission): events.append(("persist", submission service._repository = Repository() result = await service.create(request) - assert [event[0] for event in events] == ["authorize", "task", "art", "persist", "final"] + assert [event[0] for event in events] == ["authorize", "task", "persist", "art", "final"] assert result.submission_version == 1 diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 6f02065da..3e6c415d0 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1331,8 +1331,8 @@ 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) - `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) From d1d4fbea70bcf580a3710cc60c23f0c01bc6af58 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 12:11:31 +0100 Subject: [PATCH 3/8] test(tasks): prove composed submission rollback --- .../adapters/tasks/submission_composition.py | 8 +- backend/app/modules/tasks/models.py | 14 +-- .../modules/tasks/submission_composition.py | 2 +- backend/tests/test_alembic.py | 27 +++++- backend/tests/test_artifact_bindings_db.py | 86 +++++++++++++++++-- backend/tests/test_submission_composition.py | 43 ++++++++++ docs/architecture_data_model.md | 1 + 7 files changed, 159 insertions(+), 22 deletions(-) diff --git a/backend/app/adapters/tasks/submission_composition.py b/backend/app/adapters/tasks/submission_composition.py index 20a42a3e2..a198b55f6 100644 --- a/backend/app/adapters/tasks/submission_composition.py +++ b/backend/app/adapters/tasks/submission_composition.py @@ -28,6 +28,10 @@ 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: @@ -75,7 +79,3 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation authorization=self._authorization, admissions=_ArtifactAdmissionAdapter(self._admissions), ).create(request) - - async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: - del facts - raise SubmissionCreationUnavailable("submission creation is unavailable") diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index a709a68c8..e73743e66 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -187,13 +187,6 @@ class WorkstreamTask(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_workstream_tasks_locked_source_snapshot", "locked_guide_source_snapshot_id", @@ -465,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", diff --git a/backend/app/modules/tasks/submission_composition.py b/backend/app/modules/tasks/submission_composition.py index 69771bacd..e63fbb3ec 100644 --- a/backend/app/modules/tasks/submission_composition.py +++ b/backend/app/modules/tasks/submission_composition.py @@ -119,7 +119,6 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation submission_id = uuid4() submission = build_submission( submission_id=str(submission_id), task=task, - task_assignment_id=str(request.assignment_id), contributor_id=str(request.contributor_id), version=version, summary=request.summary, worker_attestation=request.contributor_attestation, supersedes_submission_id=(str(context.predecessor.submission_id) @@ -135,6 +134,7 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation ) ) 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() diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 267dbc04f..59a1143bc 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -113,7 +113,7 @@ def test_current_head_installs_submission_lineage_contract( ) command.upgrade(config, HEAD_REVISION) - async def contract() -> tuple[bool, str, list[str], 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( @@ -145,11 +145,25 @@ async def contract() -> tuple[bool, str, list[str], str]: "where c.conname='ck_submissions_artifact_lineage_shape' " "and t.relname='submissions'" ) - return bool(exists), definition, [row["column_name"] for row in columns], lineage_shape + objects = await connection.fetch( + "select conname as name from pg_constraint c join pg_class t on t.oid=c.conrelid " + "where t.relname='submissions' and conname=any($1::text[]) union all " + "select indexname as name from pg_indexes where tablename='submissions' " + "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_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, columns, lineage_shape = 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 == [ @@ -160,6 +174,13 @@ async def contract() -> tuple[bool, str, list[str], str]: ] 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..a0ed861ac 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.submission_composition 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,85 @@ 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 _set_schema(session, schema) + 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 index 202012561..2d89efab4 100644 --- a/backend/tests/test_submission_composition.py +++ b/backend/tests/test_submission_composition.py @@ -6,6 +6,7 @@ import pytest from app.modules.tasks.api import ( + SubmissionPredecessorFacts, SubmissionArtifactAdmissionResult, SubmissionCreationRequest, SubmissionCreationUnavailable, @@ -137,3 +138,45 @@ async def add_submission(self, submission): persisted.append(submission) 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 3e6c415d0..ad304ee1c 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1333,6 +1333,7 @@ Fields: - `summary` - `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) From 6315f9e9c83ade784b0fbc452c497f4a947a2a08 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 12:27:49 +0100 Subject: [PATCH 4/8] fix(tasks): remove private composition edge --- ...RCH-001-02F-task-submission-composition.md | 1 - .ci/behavior-ownership/partition.v1.json | 6 +- .../private-edge-debt.v1.json | 6 -- backend/app/adapters/tasks/__init__.py | 82 +++++++++++++++++-- .../adapters/tasks/submission_composition.py | 81 ------------------ backend/scripts/behavior_ownership.py | 1 - backend/tests/test_artifact_bindings_db.py | 2 +- 7 files changed, 78 insertions(+), 101 deletions(-) delete mode 100644 backend/app/adapters/tasks/submission_composition.py 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 8a4b3e044..3e4fc3bac 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 @@ -43,7 +43,6 @@ backend/app/modules/tasks/models.py backend/app/modules/tasks/repository.py backend/app/modules/tasks/service.py backend/app/modules/tasks/submission_composition.py -backend/app/adapters/tasks/submission_composition.py backend/app/adapters/tasks/__init__.py backend/app/main.py backend/alembic/versions/.py diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index be5f15946..0f1a1f6cf 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -28,10 +28,6 @@ "group": "lifecycle", "target": "backend/app/adapters/project_agents/openai_agent_sdk.py" }, - { - "group": "lifecycle", - "target": "backend/app/adapters/tasks/submission_composition.py" - }, { "group": "shared", "target": "backend/app/api/deps/api_controls.py" @@ -829,7 +825,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "4a06d3ea196d5aa9972b2c07019401f5fd5a8f44049745024359660e11a92b08", + "authority_digest": "6e8c9f70a0917f12e28205d7056404a29e1a0611992b7d87b59c54f96e2b5ea4", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/module-boundaries/private-edge-debt.v1.json b/.ci/module-boundaries/private-edge-debt.v1.json index 1a62e3f58..1652cc348 100644 --- a/.ci/module-boundaries/private-edge-debt.v1.json +++ b/.ci/module-boundaries/private-edge-debt.v1.json @@ -1,12 +1,6 @@ { "schema_version": 1, "edges": [ - { - "source_file": "backend/app/adapters/tasks/submission_composition.py", - "target_module": "tasks", - "imported_private_path": "app.modules.tasks.submission_composition", - "repair_owner": "WS-ARCH-001-03" - }, { "source_file": "backend/app/adapters/artifacts/__init__.py", "target_module": "actors", diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index 7747bd4a2..40697c057 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -1,13 +1,24 @@ -"""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.tasks.repository import TaskRepository -from app.adapters.tasks.submission_composition import ( - DenySubmissionCreationAuthorization, - TransactionalSubmissionCreationCommand, +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", @@ -19,3 +30,62 @@ 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/adapters/tasks/submission_composition.py b/backend/app/adapters/tasks/submission_composition.py deleted file mode 100644 index a198b55f6..000000000 --- a/backend/app/adapters/tasks/submission_composition.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Hidden composition for the TASK-owned admission-backed Submission command.""" - -from __future__ import annotations - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.artifacts.api import ( - SubmissionAdmissionConsumptionPort, - SubmissionAdmissionConsumptionRequest, -) -from app.modules.tasks.api import ( - SubmissionArtifactAdmissionRequest, - SubmissionArtifactAdmissionResult, - SubmissionCreationAuthorizationPort, - SubmissionCreationAuthorityFacts, - SubmissionCreationPreparationFacts, - SubmissionCreationRequest, - SubmissionCreationResult, - SubmissionCreationUnavailable, -) -from app.modules.tasks.submission_composition import TaskSubmissionCreationService - - -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/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 81662e0ff..6330fb675 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -88,7 +88,6 @@ ) ARCH_02F_SUBMISSION_COMPOSITION_TARGETS = frozenset( { - "backend/app/adapters/tasks/submission_composition.py", "backend/app/modules/tasks/api/submission_command.py", "backend/app/modules/tasks/submission_composition.py", } diff --git a/backend/tests/test_artifact_bindings_db.py b/backend/tests/test_artifact_bindings_db.py index a0ed861ac..61350a807 100644 --- a/backend/tests/test_artifact_bindings_db.py +++ b/backend/tests/test_artifact_bindings_db.py @@ -21,7 +21,7 @@ SubmissionAdmissionConsumptionService, ) from app.modules.artifacts.api import SubmissionAdmissionConsumptionError -from app.adapters.tasks.submission_composition import TransactionalSubmissionCreationCommand +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 608fa2922992de9a32ea209c5c5de3396888565b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 12:34:07 +0100 Subject: [PATCH 5/8] fix(migrations): preserve lineage constraint name --- backend/alembic/versions/0003_submission_artifact_lineage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/alembic/versions/0003_submission_artifact_lineage.py b/backend/alembic/versions/0003_submission_artifact_lineage.py index 8bf6aebb9..84399bf34 100644 --- a/backend/alembic/versions/0003_submission_artifact_lineage.py +++ b/backend/alembic/versions/0003_submission_artifact_lineage.py @@ -33,7 +33,7 @@ def upgrade() -> None: "uq_submissions_artifact_binding_id", "submissions", ["artifact_binding_id"] ) op.create_check_constraint( - "ck_submissions_artifact_lineage_shape", + 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 " From 9e4510140a83ee8a4ee421d71592056ca524f551 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 12:39:19 +0100 Subject: [PATCH 6/8] test(schema): lock current migration fingerprint --- .../chunks/WS-ARCH-001-02F-task-submission-composition.md | 1 + backend/tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 3e4fc3bac..180401175 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 @@ -52,6 +52,7 @@ backend/tests/test_submission_composition.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 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", From d2ae91355a3db4b61faa7614e4210a3acd977bea Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 12:48:36 +0100 Subject: [PATCH 7/8] test(tasks): open rollback proof transaction cleanly --- backend/tests/test_artifact_bindings_db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_artifact_bindings_db.py b/backend/tests/test_artifact_bindings_db.py index 61350a807..8cacace80 100644 --- a/backend/tests/test_artifact_bindings_db.py +++ b/backend/tests/test_artifact_bindings_db.py @@ -186,7 +186,8 @@ async def get_task(self, task_id, **kwargs): contributor_attestation="attestation", ) async with factory() as session: - await _set_schema(session, schema) + await session.execute(text(f'set search_path to "{schema}"')) + await session.commit() command = TransactionalSubmissionCreationCommand( session, authorization=_FinalDeny(), admissions=SubmissionAdmissionConsumptionService(session, _Allow()), From bad8ff2fbd4f72f1b5faf6272201230e6ec1b7f2 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 12 Aug 2026 13:11:30 +0100 Subject: [PATCH 8/8] fix(review): address PR 325 feedback --- ...RCH-001-02F-task-submission-composition.md | 5 ++++ ...S-ARCH-001-02F-external-review-response.md | 29 +++++++++++++++++++ backend/tests/test_alembic.py | 12 +++++--- 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02F-external-review-response.md 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 180401175..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 @@ -49,6 +49,7 @@ backend/alembic/versions/.py backend/alembic/env.py backend/tests/test_tasks.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 @@ -59,7 +60,11 @@ 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 ``` 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/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 59a1143bc..b5e8a1d9d 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -142,20 +142,24 @@ async def contract() -> tuple[bool, str, list[str], str, set[str], str]: 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 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 " - "where t.relname='submissions' and conname=any($1::text[]) union all " + "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 indexname=any($1::text[])", + "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_name='submissions' " + "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],