From 370c4aaa12f181bd527b92dac26d9406b692cded Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 11 Aug 2026 00:16:07 +0100 Subject: [PATCH 1/3] feat(tasks): expose submission context capability --- ...RCH-001-02A-task-submission-context-api.md | 5 +- .../evidence/WS-ARCH-001-02A-task-manifest.md | 73 +++ backend/app/modules/tasks/api/__init__.py | 25 + .../modules/tasks/api/submission_context.py | 110 +++++ backend/app/modules/tasks/repository.py | 119 ++++- .../architecture/test_module_boundaries.py | 15 + backend/tests/test_tasks.py | 452 +++++++++++++++++- docs/architecture_lockdown.md | 9 +- 8 files changed, 801 insertions(+), 7 deletions(-) create mode 100644 .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md create mode 100644 backend/app/modules/tasks/api/__init__.py create mode 100644 backend/app/modules/tasks/api/submission_context.py diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md index 67190599f..c6c3a5224 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md @@ -1,5 +1,8 @@ # Chunk Contract: WS-ARCH-001-02A — TASK Submission Context Public API +Implementation state: in review; on merge, this chunk is complete and +WS-ARCH-001-02B becomes the next eligible implementation boundary. + ## Parent initiative WS-ARCH-001 — Modular Monolith Boundaries @@ -73,7 +76,7 @@ mutable dictionaries in the public API; new private edges. ```bash (cd backend && .venv/bin/python -m ruff check app/modules/tasks tests/architecture/test_module_boundaries.py tests/test_tasks.py) -(cd backend && PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest -q -p pytest_asyncio.plugin -p pytest_cov.plugin tests/architecture/test_module_boundaries.py tests/test_tasks.py tests/test_submission_bundle_admission.py --cov=app.modules.tasks.api --cov-report=term-missing --cov-fail-under=90) +(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest -q -p pytest_asyncio.plugin -p pytest_cov.plugin tests/architecture/test_module_boundaries.py tests/test_tasks.py tests/test_submission_bundle_admission.py --cov=app.modules.tasks.api --cov-report=term-missing --cov-fail-under=90) (cd backend && .venv/bin/python -m scripts.module_boundaries validate --protected-base origin/main) python3 scripts/check_markdown_links.py git diff --check diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md new file mode 100644 index 000000000..7f7f0236e --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md @@ -0,0 +1,73 @@ +# WS-ARCH-001-02A TASK Public Capability Manifest + +## Public package + +`backend/app/modules/tasks/api` exposes only dependency-safe immutable contracts: + +- `TaskSubmissionContextRequest` — exact task, assignment, contributor, and + nullable predecessor selectors; +- `TaskLockedProjectContextReferences` — the immutable project, guide, + snapshot, effective-policy, and pre-submit-policy references copied onto the + TASK row; these are selectors for the future PROJECT capability, not PROJECT + policy bodies or canonical PROJECT facts; +- `SubmissionPredecessorFacts` — immediate immutable Submission ID and version; +- `TaskSubmissionContextFacts` — exact task/assignment/contributor status, + initial-or-revision kind, predecessor, and locked reference selectors; +- `TaskSubmissionContextPort` — transaction-bound lock/reload capability; +- `TaskSubmissionContextKind` — closed `initial | revision` context alias; +- `TaskSubmissionContextStatus` — closed `in_progress | needs_revision` status + alias; +- `TaskSubmissionContextFailure` — closed stable failure-code alias; +- `TaskSubmissionContextUnavailable` — stable bounded failure with + `task_submission_context_invalid` or + `task_submission_predecessor_changed`. + +The public package imports no ORM model, repository, SQLAlchemy session, +PROJECT, CHECKER, ACTOR, ART, or mutable mapping. + +## Owner-local implementation + +`TaskRepository.lock_submission_context(...)` implements the structural port +inside TASKS. Its lock order is: + +1. exact task; +2. exact assignment; +3. latest Submission for the task. + +It validates active assignment ownership, assigned contributor, allowed task +state, complete locked reference selectors, the exact latest predecessor, and +the predecessor's contributor lineage. +No ACTOR, PROJECT, CHECKER, or ART row is read. An initial context requires no +existing Submission and a null predecessor selector. A revision context is +available only in `needs_revision` and requires the exact latest Submission ID, +version, and contributor; every crossed state fails closed. + +## Boundary disposition + +This chunk adds no cross-module edge and changes no live route, schema, +Submission persistence, authorization availability, or ART behavior. The +legacy mixed `tasks.pre_submit_context` path remains frozen until 02B and 02C +provide the PROJECT and CHECKER public capabilities; 02D then migrates ART +consumers and removes the relevant private TASK edges. Unrelated frozen TASK +debt is unchanged. + +This evidence describes the implementation branch only. On merge, 02A becomes +complete and 02B becomes the next eligible implementation chunk; the global +initiative ledger must not claim that state before the human merge decision. + +## Deterministic proof + +- public API boundary validation rejects private re-exports; +- focused initial, revision, stale-predecessor, and immutable-contract tests; +- PostgreSQL-backed initial/revision state-matrix coverage, including missing, + stale, crossed-state, and cross-contributor predecessor failures; +- a two-session PostgreSQL race proving concurrent context observation blocks + behind the canonical TASK-first lock; +- 100 percent focused coverage for `app.modules.tasks.api`; +- protected-base module-boundary validation; +- Ruff, Markdown link, and diff checks. + +The dependency-free focused suite passes locally. The PostgreSQL cases are +part of the required hosted Backend/Agent Gates because this worktree has no +`WORKSTREAM_TEST_DATABASE_URL`; the chunk verification command fails fast when +that required database URL is absent. diff --git a/backend/app/modules/tasks/api/__init__.py b/backend/app/modules/tasks/api/__init__.py new file mode 100644 index 000000000..894da2f76 --- /dev/null +++ b/backend/app/modules/tasks/api/__init__.py @@ -0,0 +1,25 @@ +"""Dependency-safe public API for the TASKS business module.""" + +from app.modules.tasks.api.submission_context import ( + SubmissionPredecessorFacts, + TaskLockedProjectContextReferences, + TaskSubmissionContextFacts, + TaskSubmissionContextFailure, + TaskSubmissionContextKind, + TaskSubmissionContextPort, + TaskSubmissionContextRequest, + TaskSubmissionContextStatus, + TaskSubmissionContextUnavailable, +) + +__all__ = ( + "SubmissionPredecessorFacts", + "TaskLockedProjectContextReferences", + "TaskSubmissionContextFacts", + "TaskSubmissionContextFailure", + "TaskSubmissionContextKind", + "TaskSubmissionContextPort", + "TaskSubmissionContextRequest", + "TaskSubmissionContextStatus", + "TaskSubmissionContextUnavailable", +) diff --git a/backend/app/modules/tasks/api/submission_context.py b/backend/app/modules/tasks/api/submission_context.py new file mode 100644 index 000000000..8372fc40c --- /dev/null +++ b/backend/app/modules/tasks/api/submission_context.py @@ -0,0 +1,110 @@ +"""Public TASK contracts for locked Submission lifecycle context.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Protocol +from uuid import UUID + +TaskSubmissionContextKind = Literal["initial", "revision"] +TaskSubmissionContextStatus = Literal["in_progress", "needs_revision"] +TaskSubmissionContextFailure = Literal[ + "task_submission_context_invalid", + "task_submission_predecessor_changed", +] + + +class TaskSubmissionContextUnavailable(RuntimeError): + """Report one stable failure without exposing TASK persistence.""" + + def __init__(self, code: TaskSubmissionContextFailure) -> None: + if code not in { + "task_submission_context_invalid", + "task_submission_predecessor_changed", + }: + raise ValueError("task submission context failure code is invalid") + super().__init__(code) + self.code = code + + +@dataclass(frozen=True, slots=True) +class TaskSubmissionContextRequest: + """Exact TASK-owned selectors supplied to a transaction-bound port.""" + + task_id: UUID + assignment_id: UUID + contributor_id: UUID + predecessor_submission_id: UUID | None + + +@dataclass(frozen=True, slots=True) +class TaskLockedProjectContextReferences: + """Immutable project-policy references locked onto one TASK row.""" + + project_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_hash: str + effective_policy_id: UUID + effective_policy_hash: str + pre_submit_policy_id: UUID + pre_submit_policy_bundle_hash: str + + def __post_init__(self) -> None: + values = ( + self.guide_version, + self.source_snapshot_hash, + self.effective_policy_hash, + self.pre_submit_policy_bundle_hash, + ) + if any(not value.strip() for value in values): + raise ValueError("task locked project context reference is empty") + + +@dataclass(frozen=True, slots=True) +class SubmissionPredecessorFacts: + """TASK-owned identity of the immediate immutable Submission predecessor.""" + + submission_id: UUID + version: int + + def __post_init__(self) -> None: + if type(self.version) is not int or self.version < 1: + raise ValueError("submission predecessor version is invalid") + + +@dataclass(frozen=True, slots=True) +class TaskSubmissionContextFacts: + """Locked TASK, assignment, predecessor, and project-reference facts.""" + + task_id: UUID + assignment_id: UUID + contributor_id: UUID + status: TaskSubmissionContextStatus + kind: TaskSubmissionContextKind + predecessor: SubmissionPredecessorFacts | None + locked_project_context: TaskLockedProjectContextReferences + + def __post_init__(self) -> None: + is_initial = ( + self.status == "in_progress" + and self.kind == "initial" + and self.predecessor is None + ) + is_revision = ( + self.status == "needs_revision" + and self.kind == "revision" + and self.predecessor is not None + ) + if not (is_initial or is_revision): + raise ValueError("task submission context predecessor is inconsistent") + + +class TaskSubmissionContextPort(Protocol): + """Transaction-bound TASK capability implemented inside the TASK module.""" + + async def lock_submission_context( + self, + request: TaskSubmissionContextRequest, + ) -> TaskSubmissionContextFacts: + """Lock and return exact TASK-owned lifecycle facts.""" diff --git a/backend/app/modules/tasks/repository.py b/backend/app/modules/tasks/repository.py index c8cd9c1d2..1083059d5 100644 --- a/backend/app/modules/tasks/repository.py +++ b/backend/app/modules/tasks/repository.py @@ -4,12 +4,20 @@ from collections.abc import Sequence from datetime import datetime +from uuid import UUID from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.modules.audit.repository import AuditRepository +from app.modules.tasks.api import ( + SubmissionPredecessorFacts, + TaskLockedProjectContextReferences, + TaskSubmissionContextFacts, + TaskSubmissionContextRequest, + TaskSubmissionContextUnavailable, +) from app.modules.tasks.models import ( AuditEvent, EvidenceItem, @@ -107,6 +115,102 @@ async def get_active_assignment( ) return result.scalar_one_or_none() + async def lock_submission_context( + self, + request: TaskSubmissionContextRequest, + ) -> TaskSubmissionContextFacts: + """Lock and project exact TASK-owned Submission lifecycle facts.""" + task = await self.get_task(str(request.task_id), for_update=True) + assignment = await self._session.scalar( + select(TaskAssignment) + .where(TaskAssignment.id == str(request.assignment_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + latest_submission = await self.get_latest_submission_for_task( + str(request.task_id), + for_update=True, + populate_existing=True, + ) + contributor_id = str(request.contributor_id) + if ( + task is None + or assignment is None + or assignment.task_id != str(request.task_id) + or assignment.contributor_id != contributor_id + or assignment.status != "active" + or task.assigned_to != contributor_id + or task.status not in {"in_progress", "needs_revision"} + ): + raise TaskSubmissionContextUnavailable("task_submission_context_invalid") + + latest_id = latest_submission.id if latest_submission is not None else None + requested_predecessor_id = ( + str(request.predecessor_submission_id) + if request.predecessor_submission_id is not None + else None + ) + if latest_id != requested_predecessor_id: + raise TaskSubmissionContextUnavailable("task_submission_predecessor_changed") + if ( + (task.status == "in_progress" and latest_submission is not None) + or (task.status == "needs_revision" and latest_submission is None) + or ( + latest_submission is not None + and latest_submission.contributor_id != contributor_id + ) + ): + raise TaskSubmissionContextUnavailable("task_submission_context_invalid") + + locked_values = ( + task.project_id, + task.locked_guide_version, + task.locked_guide_source_snapshot_id, + task.locked_guide_source_snapshot_hash, + task.locked_effective_project_submission_artifact_policy_id, + task.locked_effective_project_submission_artifact_policy_hash, + task.locked_pre_submit_checker_policy_id, + task.locked_pre_submit_checker_bundle_hash, + ) + if any(value is None for value in locked_values): + raise TaskSubmissionContextUnavailable("task_submission_context_invalid") + try: + locked_project_context = TaskLockedProjectContextReferences( + project_id=UUID(task.project_id), + guide_version=task.locked_guide_version, + source_snapshot_id=UUID(task.locked_guide_source_snapshot_id), + source_snapshot_hash=task.locked_guide_source_snapshot_hash, + effective_policy_id=UUID( + task.locked_effective_project_submission_artifact_policy_id + ), + effective_policy_hash=( + task.locked_effective_project_submission_artifact_policy_hash + ), + pre_submit_policy_id=UUID(task.locked_pre_submit_checker_policy_id), + pre_submit_policy_bundle_hash=task.locked_pre_submit_checker_bundle_hash, + ) + predecessor = ( + SubmissionPredecessorFacts( + submission_id=UUID(latest_submission.id), + version=latest_submission.version, + ) + if latest_submission is not None + else None + ) + return TaskSubmissionContextFacts( + task_id=request.task_id, + assignment_id=request.assignment_id, + contributor_id=request.contributor_id, + status=task.status, + kind="revision" if predecessor is not None else "initial", + predecessor=predecessor, + locked_project_context=locked_project_context, + ) + except (TypeError, ValueError) as exc: + raise TaskSubmissionContextUnavailable( + "task_submission_context_invalid" + ) from exc + async def add_submission(self, submission: Submission) -> Submission: """Persist a submission packet and its evidence items. @@ -147,7 +251,13 @@ async def get_submission( result = await self._session.execute(statement) return result.scalar_one_or_none() - async def get_latest_submission_for_task(self, task_id: str) -> Submission | None: + async def get_latest_submission_for_task( + self, + task_id: str, + *, + for_update: bool = False, + populate_existing: bool = False, + ) -> Submission | None: """Load the latest submission version for a task. Args: @@ -156,12 +266,17 @@ async def get_latest_submission_for_task(self, task_id: str) -> Submission | Non Returns: Latest submission by version when present; otherwise ``None``. """ - result = await self._session.execute( + statement = ( select(Submission) .where(Submission.task_id == task_id) .order_by(Submission.version.desc(), Submission.submitted_at.desc()) .limit(1) ) + if for_update: + statement = statement.with_for_update() + if populate_existing: + statement = statement.execution_options(populate_existing=True) + result = await self._session.execute(statement) return result.scalar_one_or_none() async def list_submissions_for_task(self, task_id: str) -> Sequence[Submission]: diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index 54023486b..9b2d51b1c 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -193,6 +193,21 @@ def test_public_api_private_reexport_fails_closed(tmp_path: Path) -> None: ) +def test_tasks_public_api_has_no_private_or_mutable_dependency() -> None: + """TASK facts remain dependency-safe immutable public contracts.""" + boundary._validate_public_apis( # noqa: SLF001 - architecture proof + ROOT, boundary.load_registry(REGISTRY) + ) + imports = set() + for path in (ROOT / "backend/app/modules/tasks/api").rglob("*.py"): + imports.update(boundary.exact_source_imports(path, ROOT)) + assert all( + not target.startswith("app.modules.") + or target.startswith("app.modules.tasks.api") + for target in imports + ) + + def test_cyclic_public_dependencies_fail_closed() -> None: """Typed public facades cannot form an architectural dependency cycle.""" graph = {name: set() for name in boundary.load_registry(REGISTRY).names} diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 8fd7b2a4b..ea4392657 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -4,18 +4,19 @@ import hashlib import json from collections.abc import AsyncIterator, Iterator +from dataclasses import FrozenInstanceError from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path from typing import Any, cast -from unittest.mock import AsyncMock, MagicMock -from uuid import uuid4 +from unittest.mock import AsyncMock, MagicMock, call +from uuid import UUID, uuid4 import pytest # type: ignore[import-not-found] from alembic import command # type: ignore[attr-defined] from alembic.config import Config from httpx import ASGITransport, AsyncClient -from sqlalchemy import func, inspect, select, text +from sqlalchemy import func, inspect, select, text, update from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] @@ -61,6 +62,13 @@ compile_project_post_submit_checker_spec, ) from app.modules.tasks.lifecycle import InvalidTaskTransition, ensure_allowed_transition +from app.modules.tasks.api import ( + SubmissionPredecessorFacts, + TaskLockedProjectContextReferences, + TaskSubmissionContextFacts, + TaskSubmissionContextRequest, + TaskSubmissionContextUnavailable, +) from app.modules.tasks.models import ( AuditEvent, EvidenceItem, @@ -86,6 +94,244 @@ from app.schemas.auth import ActorContext +def _locked_task_context_references() -> TaskLockedProjectContextReferences: + return TaskLockedProjectContextReferences( + project_id=uuid4(), + guide_version="v1", + 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 test_task_submission_context_public_facts_are_immutable_and_consistent() -> None: + predecessor = SubmissionPredecessorFacts(submission_id=uuid4(), version=2) + facts = TaskSubmissionContextFacts( + task_id=uuid4(), + assignment_id=uuid4(), + contributor_id=uuid4(), + status="needs_revision", + kind="revision", + predecessor=predecessor, + locked_project_context=_locked_task_context_references(), + ) + + assert facts.predecessor is predecessor + failure = TaskSubmissionContextUnavailable("task_submission_context_invalid") + assert failure.code == "task_submission_context_invalid" + with pytest.raises(ValueError, match="failure code is invalid"): + TaskSubmissionContextUnavailable(cast(Any, "unbounded_failure")) + with pytest.raises(FrozenInstanceError): + facts.status = "in_progress" # type: ignore[misc] + with pytest.raises(ValueError, match="version is invalid"): + SubmissionPredecessorFacts(submission_id=uuid4(), version=0) + with pytest.raises(ValueError, match="reference is empty"): + TaskLockedProjectContextReferences( + project_id=uuid4(), + guide_version=" ", + 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, + ) + with pytest.raises(ValueError, match="predecessor is inconsistent"): + TaskSubmissionContextFacts( + task_id=uuid4(), + assignment_id=uuid4(), + contributor_id=uuid4(), + status="in_progress", + kind="initial", + predecessor=predecessor, + locked_project_context=_locked_task_context_references(), + ) + with pytest.raises(ValueError, match="predecessor is inconsistent"): + TaskSubmissionContextFacts( + task_id=uuid4(), + assignment_id=uuid4(), + contributor_id=uuid4(), + status="needs_revision", + kind="initial", + predecessor=None, + locked_project_context=_locked_task_context_references(), + ) + + +@pytest.mark.asyncio +async def test_task_repository_locks_initial_and_revision_submission_context() -> None: + contributor_id = uuid4() + task_id = uuid4() + assignment_id = uuid4() + predecessor_id = uuid4() + references = _locked_task_context_references() + task = MagicMock( + project_id=str(references.project_id), + assigned_to=str(contributor_id), + status="in_progress", + locked_guide_version=references.guide_version, + locked_guide_source_snapshot_id=str(references.source_snapshot_id), + locked_guide_source_snapshot_hash=references.source_snapshot_hash, + locked_effective_project_submission_artifact_policy_id=str( + references.effective_policy_id + ), + locked_effective_project_submission_artifact_policy_hash=( + references.effective_policy_hash + ), + locked_pre_submit_checker_policy_id=str(references.pre_submit_policy_id), + locked_pre_submit_checker_bundle_hash=references.pre_submit_policy_bundle_hash, + ) + assignment = MagicMock( + task_id=str(task_id), + contributor_id=str(contributor_id), + status="active", + ) + session = MagicMock() + session.scalar = AsyncMock(side_effect=[assignment, assignment]) + repository = TaskRepository(session) + repository.get_task = AsyncMock(return_value=task) + repository.get_latest_submission_for_task = AsyncMock( + side_effect=[ + None, + MagicMock( + id=str(predecessor_id), + version=1, + contributor_id=str(contributor_id), + ), + ] + ) + + initial = await repository.lock_submission_context( + TaskSubmissionContextRequest( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=contributor_id, + predecessor_submission_id=None, + ) + ) + assert initial == TaskSubmissionContextFacts( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=contributor_id, + status="in_progress", + kind="initial", + predecessor=None, + locked_project_context=references, + ) + task.status = "needs_revision" + revision = await repository.lock_submission_context( + TaskSubmissionContextRequest( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=contributor_id, + predecessor_submission_id=predecessor_id, + ) + ) + assert revision == TaskSubmissionContextFacts( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=contributor_id, + status="needs_revision", + kind="revision", + predecessor=SubmissionPredecessorFacts( + submission_id=predecessor_id, + version=1, + ), + locked_project_context=references, + ) + assert repository.get_task.await_args_list == [ + call(str(task_id), for_update=True), + call(str(task_id), for_update=True), + ] + assert repository.get_latest_submission_for_task.await_args_list == [ + call(str(task_id), for_update=True, populate_existing=True), + call(str(task_id), for_update=True, populate_existing=True), + ] + assignment_statements = [args.args[0] for args in session.scalar.await_args_list] + assert len(assignment_statements) == 2 + assert all("FOR UPDATE" in str(statement) for statement in assignment_statements) + assert all( + statement.get_execution_options().get("populate_existing") is True + for statement in assignment_statements + ) + + +@pytest.mark.asyncio +async def test_task_repository_rejects_stale_submission_predecessor() -> None: + task_id = uuid4() + contributor_id = uuid4() + assignment_id = uuid4() + task = MagicMock(assigned_to=str(contributor_id), status="in_progress") + assignment = MagicMock( + task_id=str(task_id), contributor_id=str(contributor_id), status="active" + ) + session = MagicMock() + session.scalar = AsyncMock(return_value=assignment) + repository = TaskRepository(session) + repository.get_task = AsyncMock(return_value=task) + repository.get_latest_submission_for_task = AsyncMock( + return_value=MagicMock( + id=str(uuid4()), version=1, contributor_id=str(contributor_id) + ) + ) + + with pytest.raises( + TaskSubmissionContextUnavailable, + match="task_submission_predecessor_changed", + ): + await repository.lock_submission_context( + TaskSubmissionContextRequest( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=contributor_id, + predecessor_submission_id=uuid4(), + ) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cross_contributor", (False, True)) +async def test_task_repository_rejects_invalid_revision_lineage( + cross_contributor: bool, +) -> None: + task_id = uuid4() + contributor_id = uuid4() + assignment_id = uuid4() + predecessor_id = uuid4() + task = MagicMock(assigned_to=str(contributor_id), status="in_progress") + assignment = MagicMock( + task_id=str(task_id), contributor_id=str(contributor_id), status="active" + ) + predecessor = MagicMock( + id=str(predecessor_id), + version=1, + contributor_id=str(uuid4()) if cross_contributor else str(contributor_id), + ) + session = MagicMock() + session.scalar = AsyncMock(return_value=assignment) + repository = TaskRepository(session) + repository.get_task = AsyncMock(return_value=task) + repository.get_latest_submission_for_task = AsyncMock(return_value=predecessor) + if cross_contributor: + task.status = "needs_revision" + + with pytest.raises( + TaskSubmissionContextUnavailable, + match="task_submission_context_invalid", + ): + await repository.lock_submission_context( + TaskSubmissionContextRequest( + task_id=task_id, + assignment_id=assignment_id, + contributor_id=contributor_id, + predecessor_submission_id=predecessor_id, + ) + ) + + async def test_task_repository_delegates_audit_persistence() -> None: """Keep legacy task audit methods as same-session shared-writer adapters.""" session = MagicMock() @@ -1320,6 +1566,206 @@ async def _wait_for_task_database_lock( raise AssertionError(f"{application_name} never reached the PostgreSQL lock") +async def _submission_context_request_for_started_task( + task_id: str, + contributor_id: str, + *, + predecessor_submission_id: str | None = None, +) -> TaskSubmissionContextRequest: + """Build a request from the canonical active assignment in PostgreSQL.""" + async with db_session.get_session_factory()() as session: + assignment_id = await session.scalar( + select(TaskAssignment.id).where( + TaskAssignment.task_id == task_id, + TaskAssignment.status == "active", + ) + ) + assert assignment_id is not None + return TaskSubmissionContextRequest( + task_id=UUID(task_id), + assignment_id=UUID(assignment_id), + contributor_id=UUID(contributor_id), + predecessor_submission_id=( + UUID(predecessor_submission_id) + if predecessor_submission_id is not None + else None + ), + ) + + +@pytest.mark.asyncio +async def test_task_repository_postgresql_submission_context_state_matrix( + task_client: AsyncClient, + task_database_env: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prove TASK context facts and crossed-state failures against PostgreSQL.""" + project = await create_active_project(task_client) + subject = "worker-submission-context" + task = await create_started_task(task_client, project["id"], monkeypatch, subject) + contributor_id = actor_id(subject) + initial_request = await _submission_context_request_for_started_task( + task["id"], contributor_id + ) + + async with db_session.get_session_factory()() as session: + initial = await TaskRepository(session).lock_submission_context(initial_request) + assert initial.kind == "initial" + assert initial.status == "in_progress" + assert initial.predecessor is None + + async with db_session.get_session_factory()() as session: + await session.execute( + update(WorkstreamTask) + .where(WorkstreamTask.id == task["id"]) + .values(status="needs_revision") + ) + await session.commit() + async with db_session.get_session_factory()() as session: + with pytest.raises( + TaskSubmissionContextUnavailable, + match="task_submission_context_invalid", + ): + await TaskRepository(session).lock_submission_context(initial_request) + + async with db_session.get_session_factory()() as session: + await session.execute( + update(WorkstreamTask) + .where(WorkstreamTask.id == task["id"]) + .values(status="in_progress") + ) + await session.commit() + monkeypatch.setattr( + "app.modules.tasks.service.enqueue_pre_review_gate", + hold_pre_review_enqueue, + ) + set_dev_actor(monkeypatch, roles="worker", subject=subject) + submission_response = await task_client.post( + f"/api/v1/tasks/{task['id']}/submissions", + headers=auth_headers(), + json=complete_submission_payload(), + ) + assert submission_response.status_code == 201, submission_response.text + predecessor = submission_response.json() + + async with db_session.get_session_factory()() as session: + await session.execute( + update(WorkstreamTask) + .where(WorkstreamTask.id == task["id"]) + .values(status="needs_revision") + ) + await session.commit() + revision_request = await _submission_context_request_for_started_task( + task["id"], contributor_id, predecessor_submission_id=predecessor["id"] + ) + async with db_session.get_session_factory()() as session: + revision = await TaskRepository(session).lock_submission_context( + revision_request + ) + assert revision.kind == "revision" + assert revision.status == "needs_revision" + assert revision.predecessor == SubmissionPredecessorFacts( + submission_id=UUID(predecessor["id"]), + version=predecessor["version"], + ) + + stale_request = TaskSubmissionContextRequest( + task_id=revision_request.task_id, + assignment_id=revision_request.assignment_id, + contributor_id=revision_request.contributor_id, + predecessor_submission_id=uuid4(), + ) + async with db_session.get_session_factory()() as session: + with pytest.raises( + TaskSubmissionContextUnavailable, + match="task_submission_predecessor_changed", + ): + await TaskRepository(session).lock_submission_context(stale_request) + + async with db_session.get_session_factory()() as session: + await session.execute( + update(WorkstreamTask) + .where(WorkstreamTask.id == task["id"]) + .values(status="in_progress") + ) + await session.commit() + async with db_session.get_session_factory()() as session: + with pytest.raises( + TaskSubmissionContextUnavailable, + match="task_submission_context_invalid", + ): + await TaskRepository(session).lock_submission_context(revision_request) + + replacement_subject = "worker-submission-context-replacement" + replacement_contributor_id = await seed_worker_profile(replacement_subject) + async with db_session.get_session_factory()() as session: + await session.execute( + update(TaskAssignment) + .where(TaskAssignment.id == str(revision_request.assignment_id)) + .values(contributor_id=replacement_contributor_id) + ) + await session.execute( + update(WorkstreamTask) + .where(WorkstreamTask.id == task["id"]) + .values( + assigned_to=replacement_contributor_id, + status="needs_revision", + ) + ) + await session.commit() + cross_contributor_request = TaskSubmissionContextRequest( + task_id=revision_request.task_id, + assignment_id=revision_request.assignment_id, + contributor_id=UUID(replacement_contributor_id), + predecessor_submission_id=revision_request.predecessor_submission_id, + ) + async with db_session.get_session_factory()() as session: + with pytest.raises( + TaskSubmissionContextUnavailable, + match="task_submission_context_invalid", + ): + await TaskRepository(session).lock_submission_context( + cross_contributor_request + ) + + +@pytest.mark.asyncio +async def test_task_repository_postgresql_submission_context_lock_serializes_race( + task_client: AsyncClient, + task_database_env: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prove the TASK row lock serializes concurrent context observation.""" + project = await create_active_project(task_client) + subject = "worker-submission-context-race" + task = await create_started_task(task_client, project["id"], monkeypatch, subject) + request = await _submission_context_request_for_started_task( + task["id"], actor_id(subject) + ) + contender_name = f"task-context-{uuid4()}" + + holder = db_session.get_session_factory()() + contender = db_session.get_session_factory()() + try: + held_facts = await TaskRepository(holder).lock_submission_context(request) + assert held_facts.kind == "initial" + await contender.execute( + text("select set_config('application_name', :application_name, true)"), + {"application_name": contender_name}, + ) + contender_call = asyncio.create_task( + TaskRepository(contender).lock_submission_context(request) + ) + await _wait_for_task_database_lock(task_database_env, contender_name) + assert not contender_call.done() + await holder.rollback() + contender_facts = await contender_call + assert contender_facts == held_facts + finally: + await holder.close() + await contender.close() + + async def _task_contributor_race_snapshot( connection: AsyncConnection, task_id: str, diff --git a/docs/architecture_lockdown.md b/docs/architecture_lockdown.md index 22b0f14d3..0a529d542 100644 --- a/docs/architecture_lockdown.md +++ b/docs/architecture_lockdown.md @@ -1,6 +1,6 @@ # Architecture Lockdown -Last updated: 2026-07-14 +Last updated: 2026-08-11 ## Purpose @@ -23,6 +23,13 @@ public-API dependency rules in Cross-module runtime imports use only the target module's typed `api` package; the composition root alone wires concrete implementations. +The TASKS public boundary exposes immutable task, assignment, immediate +Submission predecessor, and locked project-context reference selectors through +`app.modules.tasks.api`. It exposes no TASK ORM row, repository, session, +mutable policy body, or PROJECT/CHECKER/ACTOR persistence. Those locked +references are inputs to their owning public capabilities; they do not transfer +policy or identity ownership into TASKS. + The ADR files under `docs/decision_*.md` are the decision record for this lockdown. When a locked rule changes, update or add an ADR before changing implementation specs. Repository changes use the Repository-Native Human-Agent SDLC documented in From a4104e47653c4cb81eee776a2b2ff1d440c04a74 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 11 Aug 2026 00:26:01 +0100 Subject: [PATCH 2/3] fix(ci): register TASK context ownership --- .../chunks/WS-ARCH-001-02A-task-submission-context-api.md | 1 + .../evidence/WS-ARCH-001-02A-task-manifest.md | 4 +++- .ci/behavior-ownership/partition.v1.json | 6 +++++- backend/scripts/behavior_ownership.py | 4 ++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md index c6c3a5224..b3090e7cf 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md @@ -48,6 +48,7 @@ backend/app/modules/tasks/service.py backend/tests/architecture/test_module_boundaries.py backend/tests/test_tasks.py backend/tests/test_submission_bundle_admission.py +backend/scripts/behavior_ownership.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-02A-task-submission-context-api.md diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md index 7f7f0236e..de40efa30 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md @@ -46,7 +46,9 @@ version, and contributor; every crossed state fails closed. This chunk adds no cross-module edge and changes no live route, schema, Submission persistence, authorization availability, or ART behavior. The -legacy mixed `tasks.pre_submit_context` path remains frozen until 02B and 02C +new eligible TASK public implementation is registered in the existing +behavior-ownership lifecycle partition and its additive-transition allowlist. +The legacy mixed `tasks.pre_submit_context` path remains frozen until 02B and 02C provide the PROJECT and CHECKER public capabilities; 02D then migrates ART consumers and removes the relevant private TASK edges. Unrelated frozen TASK debt is unchanged. diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 1b2729349..a83f2e093 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -628,6 +628,10 @@ "group": "lifecycle", "target": "backend/app/modules/reviews/schemas.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/tasks/api/submission_context.py" + }, { "group": "lifecycle", "target": "backend/app/modules/tasks/authorization.py" @@ -761,7 +765,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "a5eda4a48b564b685080c6a387a3563c638b0e46bda0ac1b5b06d28f2f4b13ea", + "authority_digest": "b1e7516a8ceb1eccff47d177a665b5fe9c971b9cab9d420c828b7537f34e44b9", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index b281d2cad..67152a4c7 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -74,6 +74,9 @@ MODULE_BOUNDARY_FOUNDATION_TARGETS = frozenset( {"backend/scripts/module_boundaries.py"} ) +TASK_BOUNDARY_FOUNDATION_TARGETS = frozenset( + {"backend/app/modules/tasks/api/submission_context.py"} +) POL_03A_CALLABLE_TARGETS = frozenset( { "backend/app/modules/authorization/api/project_guide_compilation.py", @@ -229,6 +232,7 @@ def _validate_additive_partition_transition( approved_additions = ( AUTH_BOUNDARY_FOUNDATION_TARGETS | MODULE_BOUNDARY_FOUNDATION_TARGETS + | TASK_BOUNDARY_FOUNDATION_TARGETS | POL_03A_CALLABLE_TARGETS ) expected_additions = (approved_additions & additions) - set(trusted_targets) From e643bf80ed534a29aef8e5809722a23cb0e82115 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 11 Aug 2026 00:57:36 +0100 Subject: [PATCH 3/3] fix(tasks): address PR review findings --- ...RCH-001-02A-task-submission-context-api.md | 1 + ...S-ARCH-001-02A-external-review-response.md | 35 +++++++++++++++++++ .../modules/tasks/api/submission_context.py | 11 +++--- backend/app/modules/tasks/repository.py | 16 ++++++++- .../architecture/test_module_boundaries.py | 7 ++-- backend/tests/test_tasks.py | 11 ++++++ 6 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02A-external-review-response.md diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md index b3090e7cf..c0c38bf31 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md @@ -53,6 +53,7 @@ backend/scripts/behavior_ownership.py .ci/behavior-ownership/** .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02A-task-submission-context-api.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02A-task-manifest.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02A-external-review-response.md docs/architecture_lockdown.md ``` diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02A-external-review-response.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02A-external-review-response.md new file mode 100644 index 000000000..9e9991832 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02A-external-review-response.md @@ -0,0 +1,35 @@ +# WS-ARCH-001-02A External Review Response + +## Comments addressed + +- Derived runtime failure-code validation from the public Literal alias so the + type and runtime closed sets cannot drift. +- Made the TASK public-API import proof non-vacuous by requiring discovered API + files and discovered imports. +- Completed the changed repository method docstrings and documented the new + validation hooks and focused tests. +- Made the PostgreSQL lock-race cleanup cancel and await a pending contender + before closing either session. +- The earlier CodeRabbit CI warning is obsolete: every hosted backend gate and + shard passed at commit `a4104e47` before these review fixes. + +## Comments deferred + +None. + +## Human decisions needed + +None. + +## Commands rerun + +- Ruff over the changed TASK, boundary-test, and ownership-validator scope. +- Focused TASK public API and architecture tests with 100 percent coverage. +- Protected-base module-boundary validation, behavior-ownership validation, + Markdown-link validation, and diff integrity checks. + +## Remaining risks + +The PostgreSQL cancellation path and full backend shards must rerun in hosted +CI after this response commit. The local worktree intentionally has no test +database URL. diff --git a/backend/app/modules/tasks/api/submission_context.py b/backend/app/modules/tasks/api/submission_context.py index 8372fc40c..c08b34603 100644 --- a/backend/app/modules/tasks/api/submission_context.py +++ b/backend/app/modules/tasks/api/submission_context.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal, Protocol +from typing import Literal, Protocol, get_args from uuid import UUID TaskSubmissionContextKind = Literal["initial", "revision"] @@ -18,10 +18,8 @@ class TaskSubmissionContextUnavailable(RuntimeError): """Report one stable failure without exposing TASK persistence.""" def __init__(self, code: TaskSubmissionContextFailure) -> None: - if code not in { - "task_submission_context_invalid", - "task_submission_predecessor_changed", - }: + """Validate and retain one failure from the public closed set.""" + if code not in get_args(TaskSubmissionContextFailure): raise ValueError("task submission context failure code is invalid") super().__init__(code) self.code = code @@ -51,6 +49,7 @@ class TaskLockedProjectContextReferences: pre_submit_policy_bundle_hash: str def __post_init__(self) -> None: + """Reject incomplete locked string references.""" values = ( self.guide_version, self.source_snapshot_hash, @@ -69,6 +68,7 @@ class SubmissionPredecessorFacts: version: int def __post_init__(self) -> None: + """Reject non-positive or non-integer Submission versions.""" if type(self.version) is not int or self.version < 1: raise ValueError("submission predecessor version is invalid") @@ -86,6 +86,7 @@ class TaskSubmissionContextFacts: locked_project_context: TaskLockedProjectContextReferences def __post_init__(self) -> None: + """Enforce the exact initial-or-revision lifecycle shape.""" is_initial = ( self.status == "in_progress" and self.kind == "initial" diff --git a/backend/app/modules/tasks/repository.py b/backend/app/modules/tasks/repository.py index 1083059d5..ada09cfb1 100644 --- a/backend/app/modules/tasks/repository.py +++ b/backend/app/modules/tasks/repository.py @@ -119,7 +119,18 @@ async def lock_submission_context( self, request: TaskSubmissionContextRequest, ) -> TaskSubmissionContextFacts: - """Lock and project exact TASK-owned Submission lifecycle facts.""" + """Lock and project exact TASK-owned Submission lifecycle facts. + + Args: + request: Exact task, assignment, contributor, and predecessor selectors. + + Returns: + Immutable TASK-owned context facts from the locked rows. + + Raises: + TaskSubmissionContextUnavailable: When the context is invalid or the + requested predecessor is no longer the latest Submission. + """ task = await self.get_task(str(request.task_id), for_update=True) assignment = await self._session.scalar( select(TaskAssignment) @@ -262,6 +273,9 @@ async def get_latest_submission_for_task( Args: task_id: Task whose latest submission should be loaded. + for_update: Whether to lock the selected Submission row. + populate_existing: Whether to refresh an identity-mapped row from + PostgreSQL. Returns: Latest submission by version when present; otherwise ``None``. diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index 9b2d51b1c..5c9a8a177 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -198,9 +198,12 @@ def test_tasks_public_api_has_no_private_or_mutable_dependency() -> None: boundary._validate_public_apis( # noqa: SLF001 - architecture proof ROOT, boundary.load_registry(REGISTRY) ) - imports = set() - for path in (ROOT / "backend/app/modules/tasks/api").rglob("*.py"): + api_files = list((ROOT / "backend/app/modules/tasks/api").rglob("*.py")) + assert api_files + imports: set[str] = set() + for path in api_files: imports.update(boundary.exact_source_imports(path, ROOT)) + assert imports assert all( not target.startswith("app.modules.") or target.startswith("app.modules.tasks.api") diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index ea4392657..30f73f7e5 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -4,6 +4,7 @@ import hashlib import json from collections.abc import AsyncIterator, Iterator +from contextlib import suppress from dataclasses import FrozenInstanceError from datetime import UTC, datetime, timedelta from decimal import Decimal @@ -95,6 +96,7 @@ def _locked_task_context_references() -> TaskLockedProjectContextReferences: + """Build complete immutable locked references for focused unit tests.""" return TaskLockedProjectContextReferences( project_id=uuid4(), guide_version="v1", @@ -108,6 +110,7 @@ def _locked_task_context_references() -> TaskLockedProjectContextReferences: def test_task_submission_context_public_facts_are_immutable_and_consistent() -> None: + """Reject mutation, invalid failures, and inconsistent lifecycle facts.""" predecessor = SubmissionPredecessorFacts(submission_id=uuid4(), version=2) facts = TaskSubmissionContextFacts( task_id=uuid4(), @@ -163,6 +166,7 @@ def test_task_submission_context_public_facts_are_immutable_and_consistent() -> @pytest.mark.asyncio async def test_task_repository_locks_initial_and_revision_submission_context() -> None: + """Project exact initial and revision facts through the owner-local port.""" contributor_id = uuid4() task_id = uuid4() assignment_id = uuid4() @@ -261,6 +265,7 @@ async def test_task_repository_locks_initial_and_revision_submission_context() - @pytest.mark.asyncio async def test_task_repository_rejects_stale_submission_predecessor() -> None: + """Reject a predecessor selector that is no longer the latest Submission.""" task_id = uuid4() contributor_id = uuid4() assignment_id = uuid4() @@ -297,6 +302,7 @@ async def test_task_repository_rejects_stale_submission_predecessor() -> None: async def test_task_repository_rejects_invalid_revision_lineage( cross_contributor: bool, ) -> None: + """Reject crossed lifecycle state and cross-contributor predecessors.""" task_id = uuid4() contributor_id = uuid4() assignment_id = uuid4() @@ -1746,6 +1752,7 @@ async def test_task_repository_postgresql_submission_context_lock_serializes_rac holder = db_session.get_session_factory()() contender = db_session.get_session_factory()() + contender_call: asyncio.Task[TaskSubmissionContextFacts] | None = None try: held_facts = await TaskRepository(holder).lock_submission_context(request) assert held_facts.kind == "initial" @@ -1762,6 +1769,10 @@ async def test_task_repository_postgresql_submission_context_lock_serializes_rac contender_facts = await contender_call assert contender_facts == held_facts finally: + if contender_call is not None: + contender_call.cancel() + with suppress(asyncio.CancelledError): + await contender_call await holder.close() await contender.close()