diff --git a/.commitrail/changes/pilot14-locked-task-compensation.md b/.commitrail/changes/pilot14-locked-task-compensation.md new file mode 100644 index 000000000..86e9a6ea2 --- /dev/null +++ b/.commitrail/changes/pilot14-locked-task-compensation.md @@ -0,0 +1,137 @@ +# [PILOT-14] — Show locked task compensation before claiming + +- Initiative: None +- Durable disposition: Complete +- Intended merge outcome: Authorized project Submitters and Reviewers see exact compensation terms from each task's locked ContributionPolicyVersion in ready-work and contributor task-detail reads. + +## Intent + +Let a contributor evaluate a task's compensation before claiming it without +granting Finance-policy access or resolving the project's current policy. + +## Current behavior + +`ReadyTaskSummary` and `ContributorTaskDetail` omit compensation. TASK stores +`locked_contribution_policy_version_id` when screening a task, while the +Finance-authorized ContributionPolicy read exposes internal identifiers that +must not cross a contributor response. `task.queue.read` and `task.read` +currently accept only an active exact-project Submitter grant. + +## Bounded change + +### Allowed + +- CONTRIBUTIONS public locked-terms contracts and their owner repository/adapter. +- TASK ready queue and contributor detail contracts, projections, composition, + and existing AUTH read guards for active exact-project Submitter or Reviewer + grants. +- CLI `task ready` and `task show` response validation and text display. +- Focused real-PostgreSQL and CLI HTTP process proof, current API/CLI, + authorization, compensation and roadmap documentation, required test-lane + catalogue registration, and this record. +- The existing nine-lane runner/catalogue, focused CI contract tests and backend + testing operations guide needed to keep the exact full inventory within the + unchanged hosted execution deadline. + +### Not allowed + +- Policy creation, locking, publication, retirement, payment, award creation, + fulfillment, adapter-binding behavior, route keys or binding status. +- Migrations, task-specific term editing, Finance read access, new permissions, + lifecycle states, parallel owner implementations, or private cross-owner imports. +- Test deletion or skipping, a timeout or lane-count increase, weaker evidence + custody, fake service substitution, or a product-source workaround for CI cost. +- PILOT-13 guide-read implementation, PR #504 dispatch work, deployment, merge, + issue closure, or another implementation chunk. + +## Design and decisions + +CONTRIBUTIONS exposes one bounded public read port that projects only a locked +version UUID and the two complete contribution rules. Each rule is either the +literal `unpaid` or an immutable award list containing only `instrument`, +`unit`, and exact decimal-string `quantity`. TASK supplies only the version UUID +stored on each task and embeds the detached result in ready/detail responses. +The existing ready/detail authority paths accept either active exact-project +contributor role; claim/start and other Submitter operations remain unchanged. + +PR #505 overlapped TASK command/router schemas, CLI client/docs and roadmap files. +This change was implemented independently, then rebased onto its merged head. +The reconciliation preserves guide-document fields, delivery authority and CLI +validation alongside compensation, and adds no migration. + +## Acceptance criteria + +- [x] Unpaid locked rules show `unpaid` for both contribution types in ready and detail responses. +- [x] Paid manual-export fixtures show each exact instrument, unit and decimal-string quantity. +- [x] Publishing a successor policy cannot move an already-released task's displayed terms. +- [x] Active exact-project Submitters and Reviewers see identical terms; absent, revoked and foreign-project roles receive the existing concealed response. +- [x] Responses and CLI output contain no adapter binding ID, route key, binding status, policy lifecycle status or other Finance internals. +- [x] CLI `task ready` and `task show` validate and display the block through public HTTP. + +## Risk and review routing + +- Risk class: L1 +- Required reviewers: architecture, security, QA, test delta, reuse/dedup, documentation, ci_integrity +- Human review focus: locked-version lineage, exact-role concealment, exact decimals, + the narrow cross-owner projection, and exact CI assignment/evidence custody with + measured deadline headroom. + +## Evidence + +| Claim | Command or proof | Result | Remaining uncertainty | +|---|---|---|---| +| Locked compensation is exact and concealed | Isolated real-PostgreSQL locked terms, ready/detail/work-context/public-queue and changed AUTH matrix tests | Passed at `ec4c8505` after PR #509 reconciliation: 2 locked-term cases; the retained product source previously passed 60 owner projection cases and 7 matrix cases. Hosted Backend run `37906026854` at `8363c0e0` passed all nine lanes and the canonical aggregate across 8,805 nodes | None. | +| CLI validates and renders public output | Built CLI HTTP process tests plus `go test ./...`, `go vet ./...`, `go mod verify` and build | Passed at `677101a8`: 12 process cases and all Go checks; PILOT-14 receipt/rendering behavior and process tests are retained, while current CLI compilation and `task ready --help` passed after reconciliation | None. | +| Repository contracts remain consistent | Lane-catalogue and architecture/module-boundary tests, Ruff and compile checks | Passed at `ec4c8505`: 81 catalogue/boundary cases. After the measured lane repair, 62 focused catalogue/runner cases, Ruff, Markdown links, stale wording and Commitrail checks pass; compensation source and tests remain unchanged | Fresh exact-head hosted CI remains external evidence. | + +## Review findings + +The architecture check found that the first TASK response contract directly +referenced the CONTRIBUTIONS result type. TASK now owns its immutable response +DTO and maps from the existing CONTRIBUTIONS public port in repository +composition; the full module-boundary suite passes. Remaining exact-head +impact-routed review is coordinated by the lead. + +The first current-main hosted run at `ff62b273` used the predecessor CI lane +allocation. TASK B completed 404 of 411 collected nodes before the unchanged +1,200-second deadline, with no assertion failure and complete PostgreSQL/MinIO +cleanup; all other lanes and the CLI contract passed. Merged PR #515 moves the +measured checker workload out of TASK B while preserving the canonical inventory. +Fresh hosted evidence at `8363c0e0` passed all nine lanes and the canonical +8,805-node aggregate. TASK B completed all 380 selected nodes in 731.367 seconds +with zero skips or deselections and complete PostgreSQL/MinIO cleanup. The later +roadmap-only correction keeps PILOT-14 in open status until human merge; product +source and test blobs remain unchanged, while the documentation head requires +its own hosted checks. + +That documentation head exposed a separate measured project-lane imbalance. +Hosted run `37909246303` passed Agent Gates, MCP, preflight, MinIO, CLI and eight +of nine lanes, while project B completed 729 of 761 nodes before the unchanged +1,200-second deadline. It reported no assertion failure, skip or deselection; +PostgreSQL and MinIO cleanup completed, and the aggregate correctly rejected the +interrupted lane. The immutable lane artifact is `11606463730`. + +The first proposed repair, assigning both broad PROJECT modules solely to +project A, was rejected from retained measurements: project A took 951.944 +seconds, project C took 745.983 seconds, and the modules' retained B/C phases +would have projected project A to at least 1,189.274 seconds before hidden phases +or runner variance. The reviewed shared repair from `6256f239` instead excludes +those modules from project B and partitions their exact nodes across project A +and C with the existing hash. It preserves nine lanes, the 1,200-second deadline, +all canonical nodes, real PostgreSQL/MinIO execution and authenticated evidence; +the bounded logs retain only the 25 slowest phases. Replaying the failed run's +8,805-node manifest through the repaired catalogue retained every unique node +exactly once: 304 nodes from the two measured modules split 155 to project A and +149 to project C, with none left in project B. This PR applies only the five shared +runner, catalogue, focused-test and operations-guide files, preserving its +PILOT-14 catalogue entries and all compensation product source and tests. + +## Reconciliation + +- Current-source reconciliation: Merged `main` at `77d6db61`, preserving PR #504 + submission dispatch, PR #505 guide-document contracts, PR #509 hidden + evaluation-request delivery, PR #507's qualified gVisor experiment limits, + PR #513's guide upload and PR #515's measured CI allocation alongside + compensation and their current owner inventories. +- Next usable boundary: Human merge of this bounded PR; payment and fulfillment remain separate work. +- Remaining risks: None beyond independent review and hosted CI. diff --git a/README.md b/README.md index 43ba0ec7c..39ec49655 100644 --- a/README.md +++ b/README.md @@ -761,7 +761,11 @@ the create/screen/release role-only path with exact Project Manager authority, atomic audit evidence and durable replay. ARCH-03C4 exposes separate contributor-ready, manager-planning and Operator-status queues with current scoped grants and signed pagination. ARCH-03C5 exposes separate Contributor/Manager detail and requirements, -removing their old role/creator wrappers. ARCH-03C6 exposes separate manager, system-Operator and Audit Authority +removing their old role/creator wrappers. PILOT-14 adds the task-locked +ContributionPolicyVersion compensation block to ready summaries and Contributor +detail for active exact-project Submitters or Reviewers. It exposes only unpaid +or instrument/unit/exact-quantity terms, never Finance binding or route facts; +claim/start authority remains Submitter-only. ARCH-03C6 exposes separate manager, system-Operator and Audit Authority locked-context reads with exact current grants. ARCH-03C7 exposes bounded task history only to covered Audit Authority and removes the old payload-bearing task-only audit route. AUTH-18 delivers public manager guide activation; diff --git a/backend/app/adapters/contributions/__init__.py b/backend/app/adapters/contributions/__init__.py index 69946b7bc..b69793ce6 100644 --- a/backend/app/adapters/contributions/__init__.py +++ b/backend/app/adapters/contributions/__init__.py @@ -10,6 +10,7 @@ ContributionPolicyMutationAuthorizationPort, ContributionPolicyReadAuthorizationPort, ContributionPolicyValidationPort, + LockedCompensationTermsPort, ) from app.modules.contributions.service import ContributionPolicyService from app.modules.contributions.selected_policy_validation import ( @@ -48,3 +49,9 @@ def published_contribution_policy_port(session: AsyncSession) -> PublishedContri """Compose the bounded internal selector read; callers own disclosure authority.""" from app.modules.contributions.repository import ContributionPolicyRepository return ContributionPolicyRepository(session) + + +def locked_compensation_terms_port(session: AsyncSession) -> LockedCompensationTermsPort: + """Compose the contributor-safe locked-version projection at the CON owner.""" + from app.modules.contributions.repository import ContributionPolicyRepository + return ContributionPolicyRepository(session) diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index cb1737eb0..5b28bdab2 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -93,13 +93,21 @@ def task_commands( ) -> AuthorizedTaskCommands: """Compose TASK commands without exposing private product imports to delivery.""" from app.adapters.artifacts import task_guide_documents_port + from app.adapters.contributions import locked_compensation_terms_port return AuthorizedTaskCommands( session, authorization=authorization, audit=audit, actor_profile_id=actor_profile_id, contexts=task_service(session, settings=settings), guide_documents=task_guide_documents_port(session, settings), + compensation_terms=locked_compensation_terms_port(session), ) +def contributor_task_repository(session: AsyncSession) -> TaskRepository: + """Compose contributor reads with CON's bounded locked-terms public port.""" + from app.adapters.contributions import locked_compensation_terms_port + return TaskRepository(session, compensation_terms=locked_compensation_terms_port(session)) + + def task_submission_context_port(session: AsyncSession) -> TaskSubmissionContextPort: """Bind the public TASK submission-context port to its repository.""" return TaskRepository(session) diff --git a/backend/app/modules/authorization/artifact_project_authority.py b/backend/app/modules/authorization/artifact_project_authority.py index d60014a46..ff07bfa09 100644 --- a/backend/app/modules/authorization/artifact_project_authority.py +++ b/backend/app/modules/authorization/artifact_project_authority.py @@ -7,7 +7,8 @@ from app.modules.authorization.catalogue import ActionAvailability, ActionId from app.modules.authorization.schemas import AdminRole from app.modules.authorization.domain.task_authority import ( - TASK_ACTIONS, TASK_SUBMITTER_ACTIONS, evaluate_task_authority, + TASK_ACTIONS, TASK_PROJECT_ROLE_READ_ACTIONS, TASK_SUBMITTER_ACTIONS, + evaluate_task_authority, ) from app.modules.authorization.runtime import ( AuthorizationDenialCode, @@ -30,6 +31,8 @@ async def lock_project_authority(repository, context, scope, action, locked_context): """Dispatch the closed project-action set to its existing authority owner.""" + if action.action_id in TASK_PROJECT_ROLE_READ_ACTIONS: + return await lock_contributor_authority(repository, context, scope, locked_context) if action.action_id in PROJECT_SUBMITTER_ACTIONS: return await lock_submitter_authority(repository, context, scope, locked_context) if action.action_id not in PROJECT_AUTHORITY_ACTIONS: @@ -130,6 +133,26 @@ async def lock_submitter_authority(repository, context, scope, locked_context): return context, grant +async def lock_contributor_authority(repository, context, scope, locked_context): + """Lock one deterministic active Submitter or Reviewer grant on the exact project.""" + if ( + not isinstance(context, HumanAuthorizationContext) + or scope.kind is not PreparedAuthorityScopeKind.PROJECT + or scope.project_id is None + ): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + locked = await repository.lock_request_actor(context.identity_link_id, context.actor_profile_id) + context = locked_context(locked, context) + grant = await repository.find_active_project_role_any( + project_id=scope.project_id, + actor_profile_id=context.actor_profile_id, + for_update=True, + ) + if grant is None: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) + return context, grant + + def evaluate_submitter_authority(action, context, authority, resource, lifecycle_denial): """Evaluate the exact final facts against the locked submitter authority.""" denial = lifecycle_denial diff --git a/backend/app/modules/authorization/domain/task_authority.py b/backend/app/modules/authorization/domain/task_authority.py index 3bbd1cad7..a1c81e4fb 100644 --- a/backend/app/modules/authorization/domain/task_authority.py +++ b/backend/app/modules/authorization/domain/task_authority.py @@ -14,6 +14,7 @@ TASK_CONTRIBUTOR_READ_ACTIONS = frozenset({ ActionId.TASK_READ, ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, ActionId.TASK_WORK_CONTEXT_READ, }) +TASK_PROJECT_ROLE_READ_ACTIONS = frozenset({ActionId.TASK_READ}) TASK_MANAGER_READ_ACTIONS = frozenset({ ActionId.PROJECT_TASK_READ, ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, ActionId.PROJECT_TASK_WORK_CONTEXT_READ, @@ -24,13 +25,15 @@ ActionId.TASK_CLAIM, ActionId.TASK_START, ActionId.TASK_GUIDE_READ, - *TASK_CONTRIBUTOR_READ_ACTIONS, + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.TASK_WORK_CONTEXT_READ, } ) +TASK_PROJECT_ROLE_ACTIONS = TASK_SUBMITTER_ACTIONS | TASK_PROJECT_ROLE_READ_ACTIONS TASK_MANAGER_ACTIONS = frozenset({ ActionId.PROJECT_TASK_CREATE, ActionId.PROJECT_TASK_SCREEN, ActionId.PROJECT_TASK_RELEASE, }) -TASK_ACTIONS = TASK_SUBMITTER_ACTIONS | TASK_MANAGER_ACTIONS | TASK_MANAGER_READ_ACTIONS | TASK_LOCKED_CONTEXT_READ_ACTIONS | { +TASK_ACTIONS = TASK_PROJECT_ROLE_ACTIONS | TASK_MANAGER_ACTIONS | TASK_MANAGER_READ_ACTIONS | TASK_LOCKED_CONTEXT_READ_ACTIONS | { ActionId.OPERATIONS_TASK_START_OVERRIDE, ActionId.AUDIT_TASK_EVIDENCE_READ, } @@ -166,7 +169,7 @@ def evaluate_task_authority(action, context, authority, resource, lifecycle_deni if denial is not None: return denial, None, None, None matched_kind = ( - MatchedAuthorityKind.PROJECT_ROLE_GRANT if action.action_id in TASK_SUBMITTER_ACTIONS + MatchedAuthorityKind.PROJECT_ROLE_GRANT if action.action_id in TASK_PROJECT_ROLE_ACTIONS else MatchedAuthorityKind.ADMIN_ROLE_GRANT ) return None, matched_kind, authority.matched_grant_id, authority.scope_project_id diff --git a/backend/app/modules/authorization/domain/task_queues.py b/backend/app/modules/authorization/domain/task_queues.py index e196d30ac..19b57c59d 100644 --- a/backend/app/modules/authorization/domain/task_queues.py +++ b/backend/app/modules/authorization/domain/task_queues.py @@ -48,9 +48,9 @@ async def queue_read_denial(action, resource, context, repository, refresh, life if denial is not None: return denial, context, None, None, None, True if action.action_id is ActionId.TASK_QUEUE_READ: - grant = await repository.find_active_project_role( + grant = await repository.find_active_project_role_any( project_id=resource.scope_project_id, actor_profile_id=context.actor_profile_id, - role="submitter", for_update=True, + for_update=True, ) kind = MatchedAuthorityKind.PROJECT_ROLE_GRANT else: diff --git a/backend/app/modules/contributions/api/__init__.py b/backend/app/modules/contributions/api/__init__.py index fcef60b48..4d1eee264 100644 --- a/backend/app/modules/contributions/api/__init__.py +++ b/backend/app/modules/contributions/api/__init__.py @@ -46,6 +46,12 @@ SubmitterParticipationRequest, SubmitterParticipationResult, ) +from app.modules.contributions.api.published_selection import ( + LockedCompensationAward, + LockedCompensationTerms, + LockedCompensationTermsPort, + LockedContributionTerms, +) __all__ = ( "ContributionPolicyOperationsPort", @@ -62,6 +68,10 @@ "SubmitterParticipationPort", "SubmitterParticipationRequest", "SubmitterParticipationResult", + "LockedCompensationAward", + "LockedCompensationTerms", + "LockedCompensationTermsPort", + "LockedContributionTerms", "CompensationMode", "ContributionPolicyConflict", "ContributionPolicyCreateDraftRequest", diff --git a/backend/app/modules/contributions/api/published_selection.py b/backend/app/modules/contributions/api/published_selection.py index c1d309c42..ea1850021 100644 --- a/backend/app/modules/contributions/api/published_selection.py +++ b/backend/app/modules/contributions/api/published_selection.py @@ -1,9 +1,11 @@ -"""Internal published policy selectors; disclosure authority belongs to the caller.""" +"""Internal policy selectors; disclosure authority belongs to the caller.""" from dataclasses import dataclass -from typing import Protocol +from typing import Literal, Protocol, TypeAlias from uuid import UUID +from app.modules.compensation.api import CompensationInstrumentType + @dataclass(frozen=True, slots=True) class PublishedContributionPolicySelection: @@ -18,3 +20,56 @@ class PublishedContributionPolicyPort(Protocol): """The caller owns the project lock and authorization before disclosure.""" async def published_selection(self, project_id: UUID) -> PublishedContributionPolicySelection | None: ... + + +@dataclass(frozen=True, slots=True) +class LockedCompensationAward: + """One exact contributor-visible award definition.""" + + instrument: CompensationInstrumentType + unit: str + quantity: str + + def __post_init__(self) -> None: + if ( + not isinstance(self.instrument, CompensationInstrumentType) + or type(self.unit) is not str + or not self.unit + or type(self.quantity) is not str + or not self.quantity + ): + raise ValueError("locked compensation award is invalid") + + +LockedContributionTerms: TypeAlias = Literal["unpaid"] | tuple[LockedCompensationAward, ...] + + +@dataclass(frozen=True, slots=True) +class LockedCompensationTerms: + """The complete contributor-visible graph for one immutable policy version.""" + + contribution_policy_version_id: UUID + accepted_submission: LockedContributionTerms + completed_review: LockedContributionTerms + + def __post_init__(self) -> None: + if not isinstance(self.contribution_policy_version_id, UUID): + raise ValueError("locked compensation terms are invalid") + for value in (self.accepted_submission, self.completed_review): + if value == "unpaid": + continue + if ( + type(value) is not tuple + or not 1 <= len(value) <= 2 + or any(not isinstance(item, LockedCompensationAward) for item in value) + or len({item.instrument for item in value}) != len(value) + ): + raise ValueError("locked compensation terms are invalid") + + +class LockedCompensationTermsPort(Protocol): + """Read complete terms by exact locked version; the caller owns disclosure authority.""" + + async def read_locked_compensation_terms( + self, project_id: UUID, contribution_policy_version_ids: tuple[UUID, ...] + ) -> tuple[LockedCompensationTerms, ...]: ... diff --git a/backend/app/modules/contributions/repository.py b/backend/app/modules/contributions/repository.py index 9e1826d69..661bc620e 100644 --- a/backend/app/modules/contributions/repository.py +++ b/backend/app/modules/contributions/repository.py @@ -8,7 +8,12 @@ from sqlalchemy.orm import selectinload from sqlalchemy.orm.attributes import set_committed_value -from app.modules.contributions.api import ContributionPolicyProjectSelection +from app.modules.contributions.api import ( + ContributionPolicyProjectSelection, + LockedCompensationAward, + LockedCompensationTerms, +) +from app.modules.compensation.api import CompensationInstrumentType from app.modules.contributions.api.published_selection import PublishedContributionPolicySelection from app.modules.contributions.models import ( @@ -93,6 +98,96 @@ async def published_selection(self, project_id: UUID) -> PublishedContributionPo project, policy, version = rows[0] return PublishedContributionPolicySelection(UUID(project), policy, version) + async def read_locked_compensation_terms( + self, project_id: UUID, contribution_policy_version_ids: tuple[UUID, ...] + ) -> tuple[LockedCompensationTerms, ...]: + """Project complete terms for exact published or retired version selectors.""" + if ( + not isinstance(project_id, UUID) + or type(contribution_policy_version_ids) is not tuple + or not 1 <= len(contribution_policy_version_ids) <= 100 + or any(not isinstance(value, UUID) for value in contribution_policy_version_ids) + or len(set(contribution_policy_version_ids)) != len(contribution_policy_version_ids) + ): + raise ValueError("locked compensation selectors are invalid") + version, rule, definition = ( + ContributionPolicyVersion, + ContributionRule, + ContributionAwardDefinition, + ) + rows = ( + await self._session.execute( + select( + version.id.label("version_id"), + rule.id.label("rule_id"), + rule.contribution_type, + rule.compensation_mode, + definition.instrument_type, + definition.unit_code, + definition.quantity, + ) + .join( + rule, + and_( + rule.contribution_policy_version_id == version.id, + rule.project_id == version.project_id, + ), + ) + .outerjoin( + definition, + and_( + definition.contribution_rule_id == rule.id, + definition.contribution_policy_version_id == version.id, + definition.project_id == version.project_id, + definition.contribution_type == rule.contribution_type, + ), + ) + .where( + version.project_id == str(project_id), + version.id.in_(contribution_policy_version_ids), + version.status.in_(("published", "retired")), + ) + .order_by(version.id, rule.contribution_type, definition.instrument_type) + ) + ).all() + grouped: dict[UUID, dict[str, tuple[str, list[LockedCompensationAward]]]] = {} + for row in rows: + rules = grouped.setdefault(row.version_id, {}) + current = rules.setdefault(row.contribution_type, (row.compensation_mode, [])) + if current[0] != row.compensation_mode: + raise ValueError("locked compensation graph is invalid") + if row.instrument_type is not None: + current[1].append( + LockedCompensationAward( + instrument=CompensationInstrumentType(row.instrument_type), + unit=row.unit_code, + quantity=format(row.quantity, "f"), + ) + ) + + result: list[LockedCompensationTerms] = [] + for version_id in contribution_policy_version_ids: + rules = grouped.get(version_id) + if rules is None or set(rules) != {"accepted_submission", "completed_review"}: + continue + + def exposed(contribution_type: str): + mode, awards = rules[contribution_type] + if mode == "unpaid" and not awards: + return "unpaid" + if mode == "compensated" and 1 <= len(awards) <= 2: + return tuple(awards) + raise ValueError("locked compensation graph is invalid") + + result.append( + LockedCompensationTerms( + contribution_policy_version_id=version_id, + accepted_submission=exposed("accepted_submission"), + completed_review=exposed("completed_review"), + ) + ) + return tuple(result) + async def lock_operation(self, operation_id: UUID) -> None: """Serialize requests sharing one immutable operation identifier.""" await self._session.execute( diff --git a/backend/app/modules/tasks/api/__init__.py b/backend/app/modules/tasks/api/__init__.py index 726163c0c..d35e5b0b6 100644 --- a/backend/app/modules/tasks/api/__init__.py +++ b/backend/app/modules/tasks/api/__init__.py @@ -54,6 +54,10 @@ ReadyTaskQueuePort, TaskQueueRequest, ReadyTaskSummary, + LockedTaskCompensationUnavailable, + TaskCompensationAward, + TaskCompensationTerms, + TaskContributionTerms, ) from app.modules.tasks.api.management_queue import ( @@ -119,6 +123,10 @@ "ReadyTaskQueuePort", "TaskQueueRequest", "ReadyTaskSummary", + "LockedTaskCompensationUnavailable", + "TaskCompensationAward", + "TaskCompensationTerms", + "TaskContributionTerms", "TaskTransitionAuditPort", "TaskTransitionFacts", "TaskAuthorizationPort", diff --git a/backend/app/modules/tasks/api/ready_queue.py b/backend/app/modules/tasks/api/ready_queue.py index 9136ec327..83dcf8338 100644 --- a/backend/app/modules/tasks/api/ready_queue.py +++ b/backend/app/modules/tasks/api/ready_queue.py @@ -2,15 +2,64 @@ from dataclasses import dataclass from datetime import datetime -from typing import Protocol +from typing import Literal, Protocol, TypeAlias from uuid import UUID +class LockedTaskCompensationUnavailable(RuntimeError): + """The task's exact locked compensation graph could not be projected.""" + + def _aware(value: datetime) -> bool: """Require an exact, timezone-aware keyset position.""" return isinstance(value, datetime) and value.utcoffset() is not None +@dataclass(frozen=True, slots=True) +class TaskCompensationAward: + """One contributor-safe exact award value in a TASK response.""" + + instrument: Literal["money", "project_points"] + unit: str + quantity: str + + def __post_init__(self) -> None: + if ( + self.instrument not in ("money", "project_points") + or type(self.unit) is not str + or not self.unit + or type(self.quantity) is not str + or not self.quantity + ): + raise ValueError("task compensation award is invalid") + + +TaskContributionTerms: TypeAlias = Literal["unpaid"] | tuple[TaskCompensationAward, ...] + + +@dataclass(frozen=True, slots=True) +class TaskCompensationTerms: + """The two complete locked contribution rules disclosed by TASK.""" + + contribution_policy_version_id: UUID + accepted_submission: TaskContributionTerms + completed_review: TaskContributionTerms + + def __post_init__(self) -> None: + if not isinstance(self.contribution_policy_version_id, UUID): + raise ValueError("task compensation terms are invalid") + for value in (self.accepted_submission, self.completed_review): + if value == "unpaid": + continue + if ( + type(value) is not tuple + or not 1 <= len(value) <= 2 + or any(not isinstance(item, TaskCompensationAward) for item in value) + or len({item.instrument for item in value}) != len(value) + ): + raise ValueError("task compensation terms are invalid") + + @dataclass(frozen=True, slots=True) class TaskQueueCursor: """Project-bound live position, not an authority token or reservation.""" @@ -55,6 +104,7 @@ class ReadyTaskSummary: skill_tags: tuple[str, ...] estimated_time_minutes: int | None created_at: datetime + compensation: TaskCompensationTerms def __post_init__(self) -> None: """Keep summary facts deeply immutable and scalar.""" @@ -66,6 +116,7 @@ def __post_init__(self) -> None: or any(not isinstance(value, str) for value in self.skill_tags) or (self.estimated_time_minutes is not None and type(self.estimated_time_minutes) is not int) or not _aware(self.created_at) + or not isinstance(self.compensation, TaskCompensationTerms) ): raise ValueError("ready task summary is invalid") diff --git a/backend/app/modules/tasks/api/task_detail.py b/backend/app/modules/tasks/api/task_detail.py index 009543507..cb24d2e3f 100644 --- a/backend/app/modules/tasks/api/task_detail.py +++ b/backend/app/modules/tasks/api/task_detail.py @@ -5,7 +5,7 @@ from typing import Protocol from uuid import UUID -from app.modules.tasks.api.ready_queue import _aware +from app.modules.tasks.api.ready_queue import TaskCompensationTerms, _aware @dataclass(frozen=True, slots=True) @@ -69,7 +69,14 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class ContributorTaskDetail(_TaskDetailFields): - """Work instructions with no source, actor, policy or payment fields.""" + """Work instructions plus contributor-safe locked compensation terms.""" + + compensation: TaskCompensationTerms + + def __post_init__(self) -> None: + _TaskDetailFields.__post_init__(self) + if not isinstance(self.compensation, TaskCompensationTerms): + raise ValueError("task detail facts are invalid") @dataclass(frozen=True, slots=True) diff --git a/backend/app/modules/tasks/authorized_commands.py b/backend/app/modules/tasks/authorized_commands.py index 6e179ee1c..7ae1680c6 100644 --- a/backend/app/modules/tasks/authorized_commands.py +++ b/backend/app/modules/tasks/authorized_commands.py @@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.modules.contributions.api import LockedCompensationTermsPort from app.core.hashing import canonical_json_hash from app.db.errors import integrity_constraint_name from app.modules.tasks.api.authorization import ( @@ -66,12 +67,13 @@ def __init__( actor_profile_id: UUID, contexts: TaskService, guide_documents: TaskGuideDocumentsPort, + compensation_terms: LockedCompensationTermsPort | None = None, ) -> None: self._session = session self._authorization = authorization self._audit = audit self._actor_id = actor_profile_id - self._repo = TaskRepository(session) + self._repo = TaskRepository(session, compensation_terms=compensation_terms) self._contexts = contexts self._guide_documents = guide_documents self._replay = TaskCommandReplay(session) diff --git a/backend/app/modules/tasks/queue_router.py b/backend/app/modules/tasks/queue_router.py index 1cb21af00..982b6a5c9 100644 --- a/backend/app/modules/tasks/queue_router.py +++ b/backend/app/modules/tasks/queue_router.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.modules.tasks.repository import TaskRepository +from app.adapters.tasks import contributor_task_repository from app.api.deps.authorization import ( enforce_human_authorization_read, get_task_queue_authorization, ) @@ -56,10 +57,10 @@ async def ready_tasks( project_id: UUID, authority: Authority, session: Session, limit: PageLimit = 50, cursor: PageCursor = None, ) -> ContributorTaskQueueResponse: - """List unassigned ready work for an active exact-project Submitter.""" + """List ready work for an active exact-project Submitter or Reviewer.""" return await _page( project_id, limit, cursor, authority, session, READY_QUEUE_ACTION, - TaskRepository(session).read_ready_tasks, ContributorTaskQueueResponse, + contributor_task_repository(session).read_ready_tasks, ContributorTaskQueueResponse, ) diff --git a/backend/app/modules/tasks/repository.py b/backend/app/modules/tasks/repository.py index 1804352b3..304f18456 100644 --- a/backend/app/modules/tasks/repository.py +++ b/backend/app/modules/tasks/repository.py @@ -12,6 +12,8 @@ from sqlalchemy import Row, Select, and_, or_, select, tuple_ from sqlalchemy.ext.asyncio import AsyncSession +from app.modules.contributions.api import LockedCompensationTermsPort + from app.modules.audit.repository import AuditRepository from app.modules.tasks.api import ( AuditTaskEvidence, AuditTaskEvidencePage, AuditTaskEvidenceRequest, @@ -26,6 +28,10 @@ ReadyTaskPage, TaskQueueRequest, ReadyTaskSummary, + LockedTaskCompensationUnavailable, + TaskCompensationAward, + TaskCompensationTerms, + TaskContributionTerms, SubmissionPredecessorFacts, TaskLockedProjectContextReferences, TaskSubmissionContextFacts, @@ -68,7 +74,12 @@ def _task_detail_columns(): class TaskRepository: """Wraps SQLAlchemy persistence for task queue operations.""" - def __init__(self, session: AsyncSession) -> None: + def __init__( + self, + session: AsyncSession, + *, + compensation_terms: LockedCompensationTermsPort | None = None, + ) -> None: """Create a repository bound to one database session. Args: @@ -76,6 +87,44 @@ def __init__(self, session: AsyncSession) -> None: """ self._session = session self._audit_repository = AuditRepository(session) + self._compensation_terms = compensation_terms + + async def _locked_compensation( + self, project_id: UUID, version_ids: tuple[UUID, ...] + ) -> dict[UUID, TaskCompensationTerms]: + """Resolve every TASK-owned lock through CON's contributor-safe public port.""" + unique = tuple(dict.fromkeys(version_ids)) + if not unique: + return {} + if self._compensation_terms is None: + raise LockedTaskCompensationUnavailable("locked task compensation unavailable") + try: + with self._session.no_autoflush: + terms = await self._compensation_terms.read_locked_compensation_terms( + project_id, unique + ) + except (TypeError, ValueError) as exc: + raise LockedTaskCompensationUnavailable("locked task compensation unavailable") from exc + + def response_terms(value) -> TaskContributionTerms: + if value == "unpaid": + return "unpaid" + return tuple( + TaskCompensationAward(item.instrument.value, item.unit, item.quantity) + for item in value + ) + + mapped = { + item.contribution_policy_version_id: TaskCompensationTerms( + item.contribution_policy_version_id, + response_terms(item.accepted_submission), + response_terms(item.completed_review), + ) + for item in terms + } + if len(mapped) != len(unique) or set(mapped) != set(unique): + raise LockedTaskCompensationUnavailable("locked task compensation unavailable") + return mapped async def read_assignment_invalidation_targets_page( self, request: AssignmentInvalidationTargetsRequest, @@ -118,14 +167,20 @@ async def read_ready_tasks(self, request: TaskQueueRequest) -> ReadyTaskPage: statement = select( task.id, task.project_id, task.title, task.task_type, task.difficulty, task.skill_tags, task.estimated_time_minutes, task.created_at, + task.locked_contribution_policy_version_id, ).where(_unassigned_ready_task()) rows, continuation = await self._read_task_queue_rows(request, statement) + compensation = await self._locked_compensation( + request.project_id, + tuple(row.locked_contribution_policy_version_id for row in rows), + ) items = tuple( ReadyTaskSummary( task_id=UUID(row.id), project_id=UUID(row.project_id), title=row.title, task_type=row.task_type, difficulty=row.difficulty, skill_tags=tuple(row.skill_tags), estimated_time_minutes=row.estimated_time_minutes, created_at=row.created_at, + compensation=compensation[row.locked_contribution_policy_version_id], ) for row in rows ) @@ -201,8 +256,34 @@ async def read_contributor_task_detail(self, request: ContributorTaskDetailReque _unassigned_ready_task(), and_(task.assigned_to == str(request.contributor_id), own_assignment), )) + statement = statement.add_columns(task.locked_contribution_policy_version_id) values = await self._read_task_detail_values(request, statement) - return ContributorTaskDetail(**values) if values is not None else None + if values is None: + return None + version_id = values.pop("locked_contribution_policy_version_id") + compensation = await self._locked_compensation(request.project_id, (version_id,)) + return ContributorTaskDetail(**values, compensation=compensation[version_id]) + + async def contributor_task_visible(self, request: ContributorTaskDetailRequest) -> bool: + """Check existing contributor visibility without projecting compensation.""" + if type(request) is not ContributorTaskDetailRequest: + raise ValueError("task detail request is invalid") + task, assignment = WorkstreamTask, TaskAssignment + own_assignment = select(assignment.id).where( + assignment.task_id == task.id, + assignment.project_id == task.project_id, + assignment.status == "active", + assignment.contributor_id == str(request.contributor_id), + ).exists() + statement = select(task.id).where(or_( + _unassigned_ready_task(), + and_(task.assigned_to == str(request.contributor_id), own_assignment), + )) + statement = statement.where( + task.project_id == str(request.project_id), task.id == str(request.task_id), + ) + with self._session.no_autoflush: + return await self._session.scalar(statement) is not None async def read_management_task_detail(self, request: ManagementTaskDetailRequest) -> ManagementTaskDetail | None: """Read exact-project management facts without broad ORM loading.""" diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 2c8ed40b4..23df13499 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -163,8 +163,7 @@ async def read_contributor_task_submission_requirements( request = ContributorTaskDetailRequest(project_id, task_id, contributor_id) with self._session.no_autoflush: task = await self._lock_scoped_task(project_id, task_id) - detail = await self._repo.read_contributor_task_detail(request) - if detail is None: + if not await self._repo.contributor_task_visible(request): raise TaskNotFound("task not found") context = await self._load_locked_task_context(task) return self._contributor_submission_requirements_response(task, context) diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 79b394e1b..644416e2a 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -496,7 +496,7 @@ def lane_command( *_plugin_args(), "--cov=app", "--cov-report=", - "--durations=0", + "--durations=25", *nodes, ] return [ @@ -525,7 +525,7 @@ def admin_runner_command(nodes: list[str]) -> list[str]: *_plugin_args(), "--cov=app", "--cov-report=", - "--durations=0", + "--durations=25", *nodes, ] return [sys.executable, "-c", ADMIN_REDACTING_WRAPPER, *pytest_command] diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 5bf65776f..dba53549e 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -10,6 +10,7 @@ "project_lifecycle_b", "project_lifecycle_c", ) +PARTITIONED_PROJECT_AC_LANES = ("project_lifecycle_a", "project_lifecycle_c") PARTITIONED_TASK_LANES = ("task_lifecycle_a", "task_lifecycle_b", "task_lifecycle_c") @@ -295,7 +296,6 @@ class TestLane: "tests/projects/guide_compilation/finalization/test_authorization_concurrency_postgresql.py", "tests/projects/guide_compilation/finalization/test_authorization_postgresql.py", "tests/projects/test_active_guide_repository.py", - "tests/test_guide_document_intake.py", "tests/projects/guide_compilation/finalization/test_concurrency_postgresql.py", "tests/projects/guide_compilation/finalization/test_contracts.py", "tests/projects/guide_compilation/finalization/test_guards_postgresql.py", @@ -391,6 +391,13 @@ class TestLane: "tests/projects/sufficiency_mutations/test_replay_repository.py", "tests/projects/sufficiency_mutations/test_public_routes.py", "tests/projects/test_retired_submission_derivation_route.py", +) + +# These broad PROJECT owners exhausted project B's execution budget. Retained +# hosted timings keep their deterministic node partition on project A and C, +# excluding B while preserving headroom across both measured current trees. +PROJECT_AC_PARTITION_MODULES = ( + "tests/test_guide_document_intake.py", "tests/test_projects.py", ) @@ -422,6 +429,7 @@ class TestLane: "tests/tasks/post_submit_routing/test_contracts.py", "tests/tasks/post_submit_routing/test_storage.py", "tests/tasks/post_submit_routing/test_migration.py", + "tests/tasks/test_locked_compensation.py", "tests/tasks/test_public_queues.py", "tests/authorization/task_reads/test_authority.py", "tests/authorization/task_reads/test_contracts.py", @@ -524,6 +532,7 @@ class TestLane: PARTITIONED_PROJECT_LANES, PROJECT_MODULES + CHECKER_DELIVERY_MODULES + ROUTING_AUTH_PREPARATION_MODULES, ), + (PARTITIONED_PROJECT_AC_LANES, PROJECT_AC_PARTITION_MODULES), (PARTITIONED_TASK_LANES, TASK_MODULES), ) PARTITION_LANES_BY_MODULE = { @@ -565,7 +574,10 @@ class TestLane: *( TestLane( name, - PROJECT_MODULES + CHECKER_DELIVERY_MODULES + ROUTING_AUTH_PREPARATION_MODULES, + PROJECT_MODULES + + CHECKER_DELIVERY_MODULES + + ROUTING_AUTH_PREPARATION_MODULES + + (PROJECT_AC_PARTITION_MODULES if name in PARTITIONED_PROJECT_AC_LANES else ()), ) for name in PARTITIONED_PROJECT_LANES ), diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index e5615a3d1..b260e7b90 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -678,6 +678,8 @@ def test_policy_public_api_exports_immutable_contracts() -> None: "PolicyDefinitionView", "PolicyRuleInput", "PolicyRuleView", + "LockedCompensationAward", + "LockedCompensationTerms", ) } assert all(value.__dataclass_params__.frozen for value in public_dataclasses.values()) diff --git a/backend/tests/authorization/task_queues/test_authority.py b/backend/tests/authorization/task_queues/test_authority.py index 7b932be18..2e76ebe26 100644 --- a/backend/tests/authorization/task_queues/test_authority.py +++ b/backend/tests/authorization/task_queues/test_authority.py @@ -35,7 +35,7 @@ async def assert_queue_authority(access, project, kind, role): grant_id = await grant_queue_role(access, project, role) response = await access.signed.client.get(PATHS[kind].format(project=project), headers=actor.headers) allowed = role in { - "ready": {"submitter"}, "management": {"project_manager", "system_manager"}, + "ready": {"submitter", "reviewer"}, "management": {"project_manager", "system_manager"}, "operational": {"operator"}, }[kind] assert response.status_code == (200 if allowed else 404), response.text diff --git a/backend/tests/authorization/task_queues/test_transactions.py b/backend/tests/authorization/task_queues/test_transactions.py index 74e8b5615..4d3e81fd9 100644 --- a/backend/tests/authorization/task_queues/test_transactions.py +++ b/backend/tests/authorization/task_queues/test_transactions.py @@ -120,7 +120,7 @@ def assert_order(): assert_order() # Deliberately acquire Project before the matched grant, recreating the lock-order defect. from app.modules.authorization.repository import AdminAuthorizationRepository - method = "find_active_project_role" if kind == "ready" else "find_effective_grant" + method = "find_active_project_role_any" if kind == "ready" else "find_effective_grant" original = getattr(AdminAuthorizationRepository, method) async def reversed_locks(repository, *args, **kwargs): diff --git a/backend/tests/authorization/task_reads/test_authority.py b/backend/tests/authorization/task_reads/test_authority.py index 1b9a0a0ac..ebc1da372 100644 --- a/backend/tests/authorization/task_reads/test_authority.py +++ b/backend/tests/authorization/task_reads/test_authority.py @@ -26,7 +26,11 @@ async def test_task_read_authority_matrix(admin_access, kind): grant_id = await grant_queue_role(access, other, "project_manager") elif role != "token_only": grant_id = await grant_queue_role(access, project, role) - allowed = role in ({"submitter"} if kind.startswith("contributor") else {"project_manager", "system_manager"}) + allowed = role in ( + {"submitter", "reviewer"} if kind == "contributor_detail" + else {"submitter"} if kind == "contributor_requirements" + else {"project_manager", "system_manager"} + ) response = await access.signed.client.get(path(kind, project, task), headers=actor.headers) assert response.status_code == (200 if allowed else 404), (role, response.text) if allowed: @@ -37,6 +41,10 @@ async def test_task_read_authority_matrix(admin_access, kind): assert "locked_payment_policy_version" not in response.json() if kind.startswith("contributor"): assert "created_by" not in response.json() and "source_ref" not in response.json() + if kind == "contributor_detail": + encoded = response.text + assert "adapter_binding" not in encoded and "route_key" not in encoded + assert "binding_status" not in encoded and "version_status" not in encoded else: assert response.json()["error"]["code"] == "project_authorization_resource_not_found" async with db_session.get_session_factory()() as session: diff --git a/backend/tests/authorization/task_reads/test_concurrency.py b/backend/tests/authorization/task_reads/test_concurrency.py index 74c51296a..5806f207c 100644 --- a/backend/tests/authorization/task_reads/test_concurrency.py +++ b/backend/tests/authorization/task_reads/test_concurrency.py @@ -81,6 +81,7 @@ async def test_task_read_lock_order_and_refresh(admin_access, auth_database_env, (TaskRepository,"get_active_assignment","assignment"), (AdminAuthorizationRepository,"lock_request_actor","actor"), (AdminAuthorizationRepository,"find_active_project_role","grant"), + (AdminAuthorizationRepository,"find_active_project_role_any","grant"), (AdminAuthorizationRepository,"find_effective_grant","grant")): original = getattr(owner,method) async def trace(self,*args,_original=original,_label=label,**kwargs): diff --git a/backend/tests/projects/guide_activation/pg_support.py b/backend/tests/projects/guide_activation/pg_support.py index 5f9f042b7..7a1ba6e95 100644 --- a/backend/tests/projects/guide_activation/pg_support.py +++ b/backend/tests/projects/guide_activation/pg_support.py @@ -148,7 +148,9 @@ def activation_service(session, actor, command, grant, *, authority=True, close_ @asynccontextmanager -async def activation_case(url, *, human_review_required=True, compensated=False): +async def activation_case( + url, *, human_review_required=True, compensated=False, contribution_awards=() +): from .source_fixtures import source_case async with source_case(url) as (values, factory, finalization, actor, grant): @@ -167,7 +169,10 @@ async def activation_case(url, *, human_review_required=True, compensated=False) PostPolicyApproval(target=derived.target, idempotency_key=uuid4()), ) world, policy = await publish_policy( - factory, finalization.project_id, compensated=compensated + factory, + finalization.project_id, + compensated=compensated, + contribution_awards=contribution_awards, ) command = await activation_command(factory, approved, policy) yield factory, command, actor, grant, world, policy diff --git a/backend/tests/tasks/test_locked_compensation.py b/backend/tests/tasks/test_locked_compensation.py new file mode 100644 index 000000000..1e278c26e --- /dev/null +++ b/backend/tests/tasks/test_locked_compensation.py @@ -0,0 +1,134 @@ +"""Real PostgreSQL proof for contributor-safe locked compensation reads.""" + +from dataclasses import replace +from uuid import UUID + +from app.db import session as db_session +from tests.authorization.guide_activation.pg_support import activate +from tests.authorization.task_authority.test_postgresql import grant, project_manager +from tests.authorization.task_reads.support import create_task +from tests.projects.guide_activation.pg_support import activation_case +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + admin_access as admin_access, + signed_access as signed_access, +) +from tests.test_tasks import ( + admit_and_grant_project_submitter, + auth_headers, + create_active_project, + create_ready_task, + task_client as task_client, + task_database_env as task_database_env, +) + + +EXPECTED_PAID = { + "accepted_submission": [ + {"instrument": "money", "unit": "USD", "quantity": "2.125000000000000001"}, + {"instrument": "project_points", "unit": "PTS", "quantity": "7"}, + ], + "completed_review": [ + {"instrument": "money", "unit": "USD", "quantity": "2.125000000000000001"}, + {"instrument": "project_points", "unit": "PTS", "quantity": "7"}, + ], +} +FINANCE_INTERNALS = ( + "adapter_binding_id", + "route_key", + "binding_status", + "compensation_mode", + "policy_status", + "version_status", +) + + +async def publish_unpaid_successor(world): + result = None + for operation in ("create_draft", "update_draft", "publish"): + async with db_session.get_session_factory()() as session, session.begin(): + result = await getattr(world.service(session), operation)( + world.request(operation, result) + ) + return result + + +async def test_unpaid_ready_and_detail_share_the_task_locked_version(task_client, monkeypatch): + project = await create_active_project(task_client) + task = await create_ready_task(task_client, project["id"]) + await admit_and_grant_project_submitter( + task_client, monkeypatch, project["id"], "unpaid-compensation-reader" + ) + queue = await task_client.get( + f"/api/v1/projects/{project['id']}/tasks/ready", headers=auth_headers() + ) + detail = await task_client.get(f"/api/v1/tasks/{task['id']}", headers=auth_headers()) + assert queue.status_code == detail.status_code == 200, (queue.text, detail.text) + expected = { + "contribution_policy_version_id": task["locked_contribution_policy_version_id"], + "accepted_submission": "unpaid", + "completed_review": "unpaid", + } + assert queue.json()["items"][0]["compensation"] == expected + assert detail.json()["compensation"] == expected + assert all(name not in queue.text + detail.text for name in FINANCE_INTERNALS) + + +async def test_ready_and_detail_show_exact_locked_paid_terms_to_both_project_roles( + admin_access, +): + factory = db_session.get_session_factory() + url = factory.kw["bind"].url.render_as_string(hide_password=False) + async with activation_case( + url, contribution_awards=("money", "project_points") + ) as (sessions, command, actor, _manager_grant, world, published): + await activate(sessions, actor, command) + project = command.target.proposal.project_id + manager = await project_manager(admin_access, project) + task = await create_task(admin_access, project, manager) + await grant(admin_access, manager, project, "submitter") + + async def read(headers): + queue = await admin_access.signed.client.get( + f"/api/v1/projects/{project}/tasks/ready", headers=headers + ) + detail = await admin_access.signed.client.get( + f"/api/v1/tasks/{task}", headers=headers + ) + assert queue.status_code == detail.status_code == 200, (queue.text, detail.text) + assert len(queue.json()["items"]) == 1 + queue_terms = queue.json()["items"][0]["compensation"] + detail_terms = detail.json()["compensation"] + assert queue_terms == detail_terms == { + "contribution_policy_version_id": str( + command.contribution_policy_version_id + ), + **EXPECTED_PAID, + } + encoded = queue.text + detail.text + assert all(name not in encoded for name in FINANCE_INTERNALS) + return queue_terms + + submitter_terms = await read(admin_access.target.headers) + reviewer = await admin_access.signed.actor("locked-compensation-reviewer") + reviewer_access = replace(admin_access, target=reviewer) + await grant(reviewer_access, manager, project, "reviewer") + assert await read(reviewer.headers) == submitter_terms + + outsider = await admin_access.signed.actor("locked-compensation-outsider") + for path in ( + f"/api/v1/projects/{project}/tasks/ready", + f"/api/v1/tasks/{task}", + ): + denied = await admin_access.signed.client.get(path, headers=outsider.headers) + assert denied.status_code == 404 + assert denied.json()["error"]["code"] == "project_authorization_resource_not_found" + + successor = await publish_unpaid_successor(world) + assert successor.contribution_policy_version_id != published.contribution_policy_version_id + assert UUID(str(published.contribution_policy_version_id)) == command.contribution_policy_version_id + assert await read(admin_access.target.headers) == submitter_terms diff --git a/backend/tests/tasks/test_project_display.py b/backend/tests/tasks/test_project_display.py index dcefbcfcb..ed123e861 100644 --- a/backend/tests/tasks/test_project_display.py +++ b/backend/tests/tasks/test_project_display.py @@ -282,7 +282,7 @@ async def test_task_display_survives_guide_successor_for_contributor_and_manager assert set(contributor_body) == set(original) assert set(manager_body) == set(original) - {"lifecycle", "guide_documents"} assert set(contributor_body["task"]) == set(original["task"]) - assert set(manager_body["task"]) == set(original["task"]) | set(manager_facts) + assert set(manager_body["task"]) == (set(original["task"]) - {"compensation"}) | set(manager_facts) assert {key: manager_body["task"][key] for key in manager_facts} == manager_facts for response in (manager_body, contributor_body): assert response["guide"] == original["guide"] diff --git a/backend/tests/tasks/test_public_queues.py b/backend/tests/tasks/test_public_queues.py index 4fd513106..b233a92f7 100644 --- a/backend/tests/tasks/test_public_queues.py +++ b/backend/tests/tasks/test_public_queues.py @@ -8,6 +8,7 @@ from sqlalchemy import event, select from app.db import session as db_session +from app.adapters.contributions import locked_compensation_terms_port from app.modules.actors.models import ActorIdentityLink from app.modules.tasks.models import WorkstreamTask from app.modules.tasks.repository import TaskRepository @@ -22,12 +23,19 @@ METHODS = {"ready": "read_ready_tasks", "management": "read_management_tasks", "operational": "read_operational_tasks"} COLUMNS = { - "ready": {"id", "project_id", "title", "task_type", "difficulty", "skill_tags", "estimated_time_minutes", "created_at"}, + "ready": {"id", "project_id", "title", "task_type", "difficulty", "skill_tags", "estimated_time_minutes", "created_at", "locked_contribution_policy_version_id"}, "management": {"id", "project_id", "title", "task_type", "difficulty", "skill_tags", "estimated_time_minutes", "status", "deadline_at", "created_at", "updated_at"}, "operational": {"id", "project_id", "status", "created_at", "updated_at"}, } +def queue_repository(session, kind): + return TaskRepository( + session, + compensation_terms=locked_compensation_terms_port(session) if kind == "ready" else None, + ) + + async def queue_actor(client, monkeypatch, project, kind): if kind == "ready": return await admit_and_grant_project_submitter(client, monkeypatch, project, "queue-contributor") @@ -66,7 +74,10 @@ async def test_public_queue_pages_preserve_owner_selection(task_client, monkeypa assert page["project_id"] == project["id"] assert len(page["items"]) == 1 item = page["items"][0] - assert set(item) == (COLUMNS[kind] - {"id"}) | {"task_id"} + expected_fields = (COLUMNS[kind] - {"id", "locked_contribution_policy_version_id"}) | {"task_id"} + if kind == "ready": + expected_fields.add("compensation") + assert set(item) == expected_fields assert item["project_id"] == project["id"] seen.append(item["task_id"]) cursor = page["next_cursor"] @@ -90,7 +101,7 @@ def capture(conn, clause, *args): event.listen(engine, "before_execute", capture) try: async with db_session.get_session_factory()() as session: - page = await getattr(TaskRepository(session), METHODS[kind])(TaskQueueRequest(UUID(project["id"]))) + page = await getattr(queue_repository(session, kind), METHODS[kind])(TaskQueueRequest(UUID(project["id"]))) assert len(page.items) == 1 finally: event.remove(engine, "before_execute", capture) @@ -107,7 +118,7 @@ async def extra_column(repository, request, statement): event.listen(engine, "before_execute", capture) try: async with db_session.get_session_factory()() as session: - await getattr(TaskRepository(session), METHODS[kind])(TaskQueueRequest(UUID(project["id"]))) + await getattr(queue_repository(session, kind), METHODS[kind])(TaskQueueRequest(UUID(project["id"]))) finally: event.remove(engine, "before_execute", capture) assert len(observed) == 1 diff --git a/backend/tests/tasks/test_ready_queue.py b/backend/tests/tasks/test_ready_queue.py index 4cb6789a5..97d6f2036 100644 --- a/backend/tests/tasks/test_ready_queue.py +++ b/backend/tests/tasks/test_ready_queue.py @@ -11,8 +11,9 @@ from sqlalchemy import select from app.db import session as db_session +from app.adapters.contributions import locked_compensation_terms_port from app.modules.tasks.api import ( - TaskQueueCursor, ReadyTaskPage, TaskQueueRequest, ReadyTaskSummary, + TaskCompensationTerms, TaskQueueCursor, ReadyTaskPage, TaskQueueRequest, ReadyTaskSummary, ) from app.modules.tasks.models import TaskAssignment, WorkstreamTask from app.modules.tasks.repository import TaskRepository @@ -24,6 +25,10 @@ ) +def task_reads(session): + return TaskRepository(session, compensation_terms=locked_compensation_terms_port(session)) + + @pytest.mark.parametrize("value", [0, 101, True, False, 1.5, "5", None]) def test_ready_queue_request_validation(value): with pytest.raises(ValueError, match="request is invalid"): @@ -92,7 +97,7 @@ async def test_ready_queue_filters_before_pagination(task_client): assert foreign_row.assigned_to is None request = TaskQueueRequest(UUID(project["id"]), limit=1) expected = sorted((UUID(first["id"]), UUID(second["id"]))) - owner = TaskRepository(session) + owner = task_reads(session) page = await owner.read_ready_tasks(request) assert [item.task_id for item in page.items] == expected[:1] assert page.next_cursor == TaskQueueCursor(request.project_id, instant + timedelta(seconds=3), expected[0]) @@ -109,7 +114,7 @@ async def test_ready_queue_filters_before_pagination(task_client): missing_cursor = TaskQueueCursor(request.project_id, instant + timedelta(seconds=4), new_record_id()) async with factory() as session: assert await session.get(WorkstreamTask, str(missing_cursor.task_id)) is None - continued = await TaskRepository(session).read_ready_tasks(replace(request, after=missing_cursor)) + continued = await task_reads(session).read_ready_tasks(replace(request, after=missing_cursor)) assert continued.items == () and continued.next_cursor is None @@ -121,7 +126,7 @@ async def test_ready_queue_continues_after_claim(task_client, monkeypatch): factory = db_session.get_session_factory() request = TaskQueueRequest(UUID(project["id"]), limit=1) async with factory() as session: - page = await TaskRepository(session).read_ready_tasks(request) + page = await task_reads(session).read_ready_tasks(request) assert [item.task_id for item in page.items] == [UUID(first["id"])] assert page.next_cursor is not None await admit_and_grant_project_submitter(task_client, monkeypatch, project["id"], "queue-contributor") @@ -131,7 +136,7 @@ async def test_ready_queue_continues_after_claim(task_client, monkeypatch): assert claimed.status_code == 200, claimed.text async with factory() as session: assert (await session.get(WorkstreamTask, first["id"])).status == "claimed" - owner = TaskRepository(session) + owner = task_reads(session) for query in (request, replace(request, after=page.next_cursor)): current = await owner.read_ready_tasks(query) assert [item.task_id for item in current.items] == [UUID(second["id"])] @@ -147,7 +152,7 @@ async def test_ready_queue_assignment_visibility(task_client): assignment = _assignment(row, status="released") session.add(assignment) async with factory() as session: - page = await TaskRepository(session).read_ready_tasks(TaskQueueRequest(UUID(project["id"]))) + page = await task_reads(session).read_ready_tasks(TaskQueueRequest(UUID(project["id"]))) assert [item.task_id for item in page.items] == [UUID(task["id"])] assert page.next_cursor is None @@ -157,15 +162,16 @@ async def test_ready_queue_detached_projection(task_client): task = await create_ready_task(task_client, project["id"]) async with db_session.get_session_factory()() as session: row = await session.get(WorkstreamTask, task["id"]) - page = await TaskRepository(session).read_ready_tasks(TaskQueueRequest(UUID(project["id"]))) + page = await task_reads(session).read_ready_tasks(TaskQueueRequest(UUID(project["id"]))) expected = ReadyTaskSummary( UUID(row.id), UUID(row.project_id), row.title, row.task_type, row.difficulty, tuple(row.skill_tags), row.estimated_time_minutes, row.created_at, + TaskCompensationTerms(row.locked_contribution_policy_version_id, "unpaid", "unpaid"), ) assert page.items == (expected,) and page.next_cursor is None assert set(asdict(page.items[0])) == { "task_id", "project_id", "title", "task_type", "difficulty", "skill_tags", - "estimated_time_minutes", "created_at", + "estimated_time_minutes", "created_at", "compensation", } row.skill_tags.append("private mutation") assert page.items[0].skill_tags == ("stem", "proofs") @@ -190,10 +196,10 @@ async def test_ready_queue_preserves_transaction(task_client): request = TaskQueueRequest(UUID(project["id"])) async with factory() as session: assert not session.in_transaction() - assert len((await TaskRepository(session).read_ready_tasks(request)).items) == 1 + assert len((await task_reads(session).read_ready_tasks(request)).items) == 1 pending = WorkstreamTask(id=str(new_record_id()), project_id=project["id"]) session.add(pending) # Missing required fields: any implicit flush fails. - assert len((await TaskRepository(session).read_ready_tasks(request)).items) == 1 + assert len((await task_reads(session).read_ready_tasks(request)).items) == 1 assert pending in session.new await session.rollback() # Separate control: prove no commit without an invalid pending row masking it. @@ -201,7 +207,7 @@ async def test_ready_queue_preserves_transaction(task_client): row = await session.get(WorkstreamTask, task["id"]) row.title = "Uncommitted marker" await session.flush() - page = await TaskRepository(session).read_ready_tasks(request) + page = await task_reads(session).read_ready_tasks(request) assert page.items[0].title == "Uncommitted marker" and session.in_transaction() await session.rollback() async with factory() as observer: @@ -217,7 +223,7 @@ async def test_ready_queue_does_not_wait_for_task_lock(task_client): await writer.scalar(select(WorkstreamTask).where(WorkstreamTask.id == task["id"]).with_for_update()) async with factory() as reader: page = await asyncio.wait_for( - TaskRepository(reader).read_ready_tasks(TaskQueueRequest(UUID(project["id"]))), + task_reads(reader).read_ready_tasks(TaskQueueRequest(UUID(project["id"]))), timeout=5, ) assert [item.task_id for item in page.items] == [UUID(task["id"])] diff --git a/backend/tests/tasks/test_submission_requirements.py b/backend/tests/tasks/test_submission_requirements.py index b6776e234..22f821d22 100644 --- a/backend/tests/tasks/test_submission_requirements.py +++ b/backend/tests/tasks/test_submission_requirements.py @@ -264,22 +264,22 @@ async def test_requirement_waits_for_task_before_projects(task_client): await writer.flush() service = task_service(reader, settings=get_settings()) order = MagicMock() - with patch.object(service._repo, "read_contributor_task_detail", wraps=service._repo.read_contributor_task_detail) as detail, patch.object(service._project_contexts, "lock_locked_policy_context", wraps=service._project_contexts.lock_locked_policy_context) as resolve: - order.attach_mock(detail, "detail") + with patch.object(service._repo, "contributor_task_visible", wraps=service._repo.contributor_task_visible) as visibility, patch.object(service._project_contexts, "lock_locked_policy_context", wraps=service._project_contexts.lock_locked_policy_context) as resolve: + order.attach_mock(visibility, "visibility") order.attach_mock(resolve, "projects") pending = asyncio.create_task(service.read_contributor_task_submission_requirements(UUID(project["id"]), UUID(task["id"]), new_record_id())) try: await asyncio.wait_for(wait_for_named_database_lock(get_settings().database_url, name, expected_waiter_pid=reader_pid, expected_blocker_pid=writer_pid), timeout=10) - detail.assert_not_awaited() + visibility.assert_not_awaited() resolve.assert_not_awaited() assert not pending.done() and stored.locked_post_submit_checker_policy_body == original_body await writer.commit() with pytest.raises(TaskLockedContextInvalid, match="custody is invalid"): await asyncio.wait_for(pending, timeout=10) - detail.assert_awaited_once() + visibility.assert_awaited_once() resolve.assert_awaited_once() - assert [call[0] for call in order.mock_calls] == ["detail", "projects"] + assert [call[0] for call in order.mock_calls] == ["visibility", "projects"] assert stored.locked_post_submit_checker_policy_body["blocking_severities"] == [] finally: if not pending.done(): diff --git a/backend/tests/tasks/test_task_detail.py b/backend/tests/tasks/test_task_detail.py index ed90c3ec6..c82d7854b 100644 --- a/backend/tests/tasks/test_task_detail.py +++ b/backend/tests/tasks/test_task_detail.py @@ -11,10 +11,12 @@ from sqlalchemy import select from app.db import session as db_session +from app.adapters.contributions import locked_compensation_terms_port from app.main import create_app from app.modules.tasks.api import ( ContributorTaskDetail, ContributorTaskDetailRequest, ManagementTaskDetail, ManagementTaskDetailRequest, + TaskCompensationTerms, ) from app.modules.tasks.lifecycle import ALLOWED_TASK_TRANSITIONS from app.modules.tasks.models import TaskAssignment, WorkstreamTask @@ -43,8 +45,10 @@ def request_for(method, project_id, task_id, contributor_id=None): async def read_once(session, method, request): with patch.object(session, "execute", wraps=session.execute) as execute: - result = await getattr(TaskRepository(session), method)(request) - execute.assert_awaited_once() + result = await getattr( + TaskRepository(session, compensation_terms=locked_compensation_terms_port(session)), method + )(request) + assert execute.await_count == (2 if method == READS[0] and result is not None else 1) return result @@ -61,11 +65,14 @@ def test_task_detail_contracts(): values = dict(task_id=task, project_id=project, title="Title", description="Instructions", task_type=None, difficulty=None, skill_tags=("tag",), estimated_time_minutes=None, status="ready", acceptance_criteria=None, rejection_criteria=None, deadline_at=None, created_at=now, updated_at=now) - for item in (ContributorTaskDetail(**values), ManagementTaskDetail( + compensation = TaskCompensationTerms(new_record_id(), "unpaid", "unpaid") + for item in (ContributorTaskDetail(**values, compensation=compensation), ManagementTaskDetail( **values, source_type="manual", source_ref=None, source_payload_hash=None, import_batch_id=None, external_task_id=None, created_by="creator", assigned_to=None, )): - assert set(asdict(item)) == COMMON | (MANAGEMENT if type(item) is ManagementTaskDetail else set()) + assert set(asdict(item)) == COMMON | ( + MANAGEMENT if type(item) is ManagementTaskDetail else {"compensation"} + ) for field, value in asdict(item).items(): with pytest.raises(ValueError, match="facts are invalid"): replace(item, **{field: []}) @@ -218,19 +225,34 @@ async def test_task_detail_projection(task_client, method): assert row.deadline_at == deadline and row.assigned_to is None expected = {name: getattr(row, name) for name in COMMON - {"task_id", "project_id", "skill_tags"}} expected |= dict(task_id=UUID(row.id), project_id=UUID(row.project_id), skill_tags=tuple(row.skill_tags)) - fields = COMMON | (MANAGEMENT if method == READS[1] else set()) + fields = COMMON | (MANAGEMENT if method == READS[1] else {"compensation"}) if method == READS[1]: expected |= {name: getattr(row, name) for name in MANAGEMENT} + else: + expected["compensation"] = asdict( + TaskCompensationTerms( + row.locked_contribution_policy_version_id, "unpaid", "unpaid" + ) + ) execute = session.execute async def checked_execute(statement, *args, **kwargs): result = await execute(statement, *args, **kwargs) # Actual database query, not a canned result. - assert set(statement.selected_columns.keys()) == fields + selected = set(statement.selected_columns.keys()) + expected_selected = fields if method == READS[1] else (fields - {"compensation"}) | { + "locked_contribution_policy_version_id" + } + assert selected == expected_selected or selected == { + "version_id", "rule_id", "contribution_type", "compensation_mode", + "instrument_type", "unit_code", "quantity", + } return result with patch.object(session, "execute", wraps=checked_execute) as observed: - detail = await getattr(TaskRepository(session), method)(request_for(method, project["id"], row.id)) - observed.assert_awaited_once() + detail = await getattr( + TaskRepository(session, compensation_terms=locked_compensation_terms_port(session)), method + )(request_for(method, project["id"], row.id)) + assert observed.await_count == (2 if method == READS[0] else 1) assert type(detail) is (ManagementTaskDetail if method == READS[1] else ContributorTaskDetail) assert asdict(detail) == expected assert set(asdict(detail)) == fields diff --git a/backend/tests/tasks/test_work_context.py b/backend/tests/tasks/test_work_context.py index 33c13cbb3..6949ecd6f 100644 --- a/backend/tests/tasks/test_work_context.py +++ b/backend/tests/tasks/test_work_context.py @@ -15,7 +15,11 @@ from app.main import create_app from app.modules.projects.api.guide_activation_context import GuidePolicySelection from app.modules.projects.api.locked_policy import GuideDisplayFacts, ProjectDisplayFacts -from app.modules.tasks.api import ContributorTaskDetail, ManagementTaskDetail +from app.modules.tasks.api import ( + ContributorTaskDetail, + ManagementTaskDetail, + TaskCompensationTerms, +) from app.modules.tasks.schemas import ( ContributorTaskLifecycle, ContributorTaskWorkContext, ManagementTaskWorkContext, ) @@ -36,19 +40,25 @@ "deadline_at", "created_at", "updated_at"} MANAGEMENT = {"source_type", "source_ref", "source_payload_hash", "import_batch_id", "external_task_id", "created_by", "assigned_to"} +CONTRIBUTOR = COMMON | {"compensation"} CONTEXT = {"task", "project", "guide", "review_policy", "revision_policy", "contribution_policy_version_id"} ACTIONS = ("task.work_context.read", "project.task.work_context.read") def context_values(): project_id, now = new_record_id(), datetime.now(UTC) - task = ContributorTaskDetail(new_record_id(), project_id, "Title", "Work", None, None, ("tag",), None, - "claimed", None, None, None, now, now) + task = ContributorTaskDetail( + new_record_id(), project_id, "Title", "Work", None, None, ("tag",), None, + "claimed", None, None, None, now, now, + TaskCompensationTerms(new_record_id(), "unpaid", "unpaid"), + ) selection = GuidePolicySelection(policy_id=new_record_id(), generation=1, policy_hash="sha256:" + "1" * 64) values = dict(project=ProjectDisplayFacts(project_id, "Project", "project", None), guide=GuideDisplayFacts(new_record_id(), project_id, "guide", None, now), review_policy=selection, revision_policy=selection, contribution_policy_version_id=new_record_id()) - manager = ManagementTaskDetail(**asdict(task), source_type="manual", source_ref="private", + task_values = asdict(task) + task_values.pop("compensation") + manager = ManagementTaskDetail(**task_values, source_type="manual", source_ref="private", source_payload_hash=None, import_batch_id=None, external_task_id=None, created_by="creator", assigned_to="owner") return task, manager, values @@ -84,8 +94,11 @@ def test_work_context_contracts(): kind: values[kind].model_dump() | {field: invalid}, })) subclass = type("UnexpectedTaskDetail", (type(detail),), {}) + subclass_values = asdict(detail) + if isinstance(detail, ContributorTaskDetail): + subclass_values["compensation"] = detail.compensation with pytest.raises(ValidationError, match="work context task is invalid"): - cls(task=subclass(**asdict(detail)), **values, **extra) + cls(task=subclass(**subclass_values), **values, **extra) with pytest.raises(ValidationError, match="extra_forbidden"): cls(task=detail, **values, **extra, base_amount=25) for extra in ({"next_actions": ("submit",)}, {"next_actions": ("claim", "start")}, @@ -110,7 +123,7 @@ def test_work_context_openapi(): assert operation["responses"]["200"]["content"]["application/json"]["schema"] == {"$ref": f"#/components/schemas/{name}"} assert set(definitions[name]["properties"]) == fields assert definitions[name]["additionalProperties"] is False - assert set(definitions["ContributorTaskDetail"]["properties"]) == COMMON + assert set(definitions["ContributorTaskDetail"]["properties"]) == CONTRIBUTOR assert set(definitions["ManagementTaskDetail"]["properties"]) == COMMON | MANAGEMENT assert set(definitions["ContributorTaskLifecycle"]["properties"]) == {"assigned_to_current_actor", "next_actions"} assert set(definitions["GuidePolicySelection"]["properties"]) == {"policy_id", "generation", "policy_hash"} @@ -179,15 +192,25 @@ async def test_work_context_public_projections(task_client, monkeypatch): response = await task_client.get(f"{path}/tasks/{task['id']}/work-context", headers=auth_headers()) assert response.status_code == 200, response.text body = response.json() - fields = COMMON | (MANAGEMENT if management else set()) + fields = (COMMON | MANAGEMENT) if management else CONTRIBUTOR assert set(body) == CONTEXT | (set() if management else {"lifecycle", "guide_documents"}) assert set(body["task"]) == fields - for name in fields - {"created_at", "updated_at", "deadline_at"}: + for name in fields - {"created_at", "updated_at", "deadline_at", "compensation"}: assert body["task"][name] == stored["id" if name == "task_id" else name], name for name in ("created_at", "updated_at", "deadline_at"): assert datetime.fromisoformat(body["task"][name]) == stored[name] if not management: - assert body["lifecycle"] == {"assigned_to_current_actor": True, "next_actions": ["start"]} + assert body["lifecycle"] == { + "assigned_to_current_actor": True, + "next_actions": ["start"], + } + assert body["task"]["compensation"] == { + "contribution_policy_version_id": str( + stored["locked_contribution_policy_version_id"] + ), + "accepted_submission": "unpaid", + "completed_review": "unpaid", + } assert "PRIVATE" not in response.text for kind in ("review", "revision"): assert body[f"{kind}_policy"] == { diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 1701603b1..fa1a9e9da 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -83,15 +83,36 @@ def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> ) +def test_measured_project_modules_partition_between_project_a_and_c() -> None: + expected = { + "tests/test_guide_document_intake.py", + "tests/test_projects.py", + } + assert set(catalogue.PROJECT_AC_PARTITION_MODULES) == expected + assert not expected & set(catalogue.PROJECT_MODULES) + assert all( + catalogue.PARTITION_LANES_BY_MODULE[module] == catalogue.PARTITIONED_PROJECT_AC_LANES + for module in expected + ) + + for module in expected: + assert tuple(lane.name for lane in LANES if module in lane.modules) == ( + "project_lifecycle_a", + "project_lifecycle_c", + ) + + nodes = [f"{module}::test_owned[{index}]" for module in expected for index in range(256)] + manifest = runner.build_manifest("a" * 40, list(reversed(nodes))) + assert {row["nodeid"] for row in manifest["nodes"]} == set(nodes) + assert {row["lane"] for row in manifest["nodes"]} == set(catalogue.PARTITIONED_PROJECT_AC_LANES) + + def test_measured_hotspots_have_explicit_semantic_owners() -> None: """Keep lane balance tied to subsystem ownership and measured schema cost.""" modules_by_lane = {lane.name: set(lane.modules) for lane in LANES} - assert ( - modules_by_lane["project_lifecycle_a"] - == modules_by_lane["project_lifecycle_b"] - == modules_by_lane["project_lifecycle_c"] - == set(catalogue.CHECKER_DELIVERY_MODULES) + partitioned_project_modules = ( + set(catalogue.CHECKER_DELIVERY_MODULES) | set(catalogue.ROUTING_AUTH_PREPARATION_MODULES) | { "tests/tasks/evaluation_delivery/test_custody.py", @@ -128,7 +149,6 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/projects/guide_activation/test_rejections.py", "tests/projects/guide_activation/test_successor.py", "tests/projects/test_active_guide_repository.py", - "tests/test_guide_document_intake.py", "tests/projects/guide_compilation/test_capability_growth.py", "tests/projects/guide_compilation/test_capability_growth_postgresql.py", "tests/projects/guide_compilation/test_compilation_storage_limit.py", @@ -230,9 +250,12 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/projects/submission_policy_mutations/test_public_routes_postgresql.py", "tests/projects/test_retired_submission_derivation_route.py", "tests/test_api_drill_repairs.py", - "tests/test_projects.py", } ) + assert modules_by_lane["project_lifecycle_b"] == partitioned_project_modules + project_ac_modules = partitioned_project_modules | set(catalogue.PROJECT_AC_PARTITION_MODULES) + assert modules_by_lane["project_lifecycle_a"] == project_ac_modules + assert modules_by_lane["project_lifecycle_c"] == project_ac_modules assert ( modules_by_lane["task_lifecycle_a"] == modules_by_lane["task_lifecycle_b"] @@ -257,6 +280,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/tasks/test_project_display.py", "tests/tasks/test_ready_queue.py", "tests/tasks/test_public_queues.py", + "tests/tasks/test_locked_compensation.py", "tests/authorization/task_queues/test_authority.py", "tests/authorization/task_queues/test_contracts.py", "tests/authorization/task_queues/test_transactions.py", @@ -495,7 +519,9 @@ def test_schema_nodes_share_one_lane() -> None: @pytest.mark.parametrize( - ("names", "modules"), catalogue.PARTITION_GROUPS, ids=("shared", "project", "task") + ("names", "modules"), + catalogue.PARTITION_GROUPS, + ids=("shared", "project", "project-ac", "task"), ) def test_owner_nodes_partition_deterministically(names, modules) -> None: module = modules[0] diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index 65cfb1e3b..3a580f453 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -98,12 +98,12 @@ def test_lane_command_uses_exact_nodes_and_isolation_contract(tmp_path: Path) -> assert command[-1] == nodes[0] assert "--cov=app" in command assert "--cov-report=" in command - assert "--durations=0" in command - assert "--durations=25" not in command + assert command.count("--durations=25") == 1 + assert "--durations=0" not in command admin = runner.admin_runner_command([f"{runner.ADMIN_RUNNER_MODULE}::test_one"]) - assert "--durations=0" in admin - assert "--durations=25" not in admin + assert admin.count("--durations=25") == 1 + assert "--durations=0" not in admin def test_lane_environment_uses_private_evidence_and_coverage(tmp_path: Path) -> None: diff --git a/backend/tests/test_pre_submit_related_lock_order.py b/backend/tests/test_pre_submit_related_lock_order.py index 0f861a583..0b9ab6289 100644 --- a/backend/tests/test_pre_submit_related_lock_order.py +++ b/backend/tests/test_pre_submit_related_lock_order.py @@ -3,6 +3,7 @@ from __future__ import annotations from app.adapters.artifacts import task_guide_documents_port +from app.adapters.contributions import locked_compensation_terms_port from app.core.config import get_settings from app.adapters.tasks import task_service @@ -326,6 +327,7 @@ async def pause_before_auth(facts): actor_profile_id=harness.actor_id, contexts=task_service(task_session, settings=get_settings()), guide_documents=task_guide_documents_port(task_session, get_settings()), + compensation_terms=locked_compensation_terms_port(task_session), ) try: task_command = asyncio.create_task(commands.contributor_work_context(harness.request.task_id)) diff --git a/cli/README.md b/cli/README.md index 7b956aef5..f6390666d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -272,10 +272,10 @@ workstream task show TASK_ID --output json ``` These are contributor routes, not aliases for manager browsing. The ready queue -requires an active project and its exact active Submitter grant; Manager or -Reviewer authority alone does not permit it. It lists only unassigned ready -tasks. Contributor detail shows unassigned ready work or the caller's own active -assignment under current Submitter authority. It does not expose management +requires an active project and an exact active Submitter or Reviewer grant; +Manager authority alone does not permit it. It lists only unassigned ready +tasks. Contributor detail shows unassigned ready work to either contributor role, +or the caller's own active assignment under current Submitter authority. It does not expose management source/actor/assignment metadata, and a different same-project Submitter cannot read your claimed task. Workstream makes these decisions on every request. @@ -284,13 +284,19 @@ validation and safe text/raw-JSON output apply. Ready summaries contain task and project IDs, title, nullable type/difficulty/estimated minutes, skills and creation time. Detail adds instructions, criteria, status, deadline and update time; nullable detail fields may be omitted by the API and display as `—`. +Both responses include compensation from the task's locked +ContributionPolicyVersion: the exact version UUID and, for accepted submissions +and completed reviews, either `unpaid` or award rows containing only instrument, +unit and exact decimal-string quantity. The CLI rejects binding IDs, route keys, +binding status and other unknown Finance fields. `task show` takes only a task selector; Workstream resolves its project and authorizes that resource. No project preflight or locally inferred permission is added. Management-only fields in a contributor reply are rejected rather than silently displayed or ignored. Discovery is live, not a reservation or a claimability guarantee. A later claim -must revalidate current authority and state. Cursors are action/project/limit +still requires a Submitter grant and must revalidate current authority and state. +Cursors are action/project/limit bound and cannot be reused as manager cursors; the CLI never decodes them or automatically fetches another page. These reads do not claim/start tasks, upload submissions or complete unfinished acceptance integration. diff --git a/cli/internal/api/contributor_tasks.go b/cli/internal/api/contributor_tasks.go index 95447d180..4bf1fc786 100644 --- a/cli/internal/api/contributor_tasks.go +++ b/cli/internal/api/contributor_tasks.go @@ -2,21 +2,38 @@ package api import ( "context" + "encoding/json" + jsonv2 "encoding/json/v2" "errors" "net/http" "net/url" + "regexp" + "strings" ) +type CompensationAward struct { + Instrument string `json:"instrument"` + Unit string `json:"unit"` + Quantity string `json:"quantity"` +} + +type CompensationTerms struct { + ContributionPolicyVersionID string `json:"contribution_policy_version_id"` + AcceptedSubmission json.RawMessage `json:"accepted_submission"` + CompletedReview json.RawMessage `json:"completed_review"` +} + // ReadyTaskSummary is the public contributor projection, not management facts. type ReadyTaskSummary struct { - TaskID string `json:"task_id"` - ProjectID string `json:"project_id"` - Title string `json:"title"` - TaskType *string `json:"task_type"` - Difficulty *string `json:"difficulty"` - SkillTags []string `json:"skill_tags"` - EstimatedTimeMinutes *int `json:"estimated_time_minutes"` - CreatedAt string `json:"created_at"` + TaskID string `json:"task_id"` + ProjectID string `json:"project_id"` + Title string `json:"title"` + TaskType *string `json:"task_type"` + Difficulty *string `json:"difficulty"` + SkillTags []string `json:"skill_tags"` + EstimatedTimeMinutes *int `json:"estimated_time_minutes"` + CreatedAt string `json:"created_at"` + Compensation CompensationTerms `json:"compensation"` } type ReadyTaskPage struct { @@ -28,9 +45,44 @@ type ReadyTaskPage struct { // ContributorTaskDetail excludes management-only source and actor metadata. type ContributorTaskDetail struct { TaskSummary - Description string `json:"description"` - AcceptanceCriteria *string `json:"acceptance_criteria"` - RejectionCriteria *string `json:"rejection_criteria"` + Description string `json:"description"` + AcceptanceCriteria *string `json:"acceptance_criteria"` + RejectionCriteria *string `json:"rejection_criteria"` + Compensation CompensationTerms `json:"compensation"` +} + +var exactQuantity = regexp.MustCompile(`^(0|[1-9][0-9]*)(\.[0-9]+)?$`) + +func compensationAwards(raw json.RawMessage) ([]CompensationAward, bool) { + var unpaid string + if err := jsonv2.Unmarshal(raw, &unpaid); err == nil { + return nil, unpaid == "unpaid" + } + var awards []CompensationAward + if err := jsonv2.Unmarshal(raw, &awards, jsonv2.RejectUnknownMembers(true)); err != nil || len(awards) < 1 || len(awards) > 2 { + return nil, false + } + seen := make(map[string]bool) + for _, award := range awards { + validInstrument := award.Instrument == "money" || award.Instrument == "project_points" + if !validInstrument || award.Unit == "" || !exactQuantity.MatchString(award.Quantity) || + !strings.ContainsAny(award.Quantity, "123456789") || seen[award.Instrument] { + return nil, false + } + seen[award.Instrument] = true + } + return awards, true +} + +func ValidCompensation(value CompensationTerms) bool { + _, validID := uuidIdentity(value.ContributionPolicyVersionID) + _, validAccepted := compensationAwards(value.AcceptedSubmission) + _, validReviewed := compensationAwards(value.CompletedReview) + return validID && validAccepted && validReviewed +} + +func CompensationAwards(raw json.RawMessage) ([]CompensationAward, bool) { + return compensationAwards(raw) } func (c *Client) ReadyTasks(ctx context.Context, project string, limit int, cursor *string) (Result[ReadyTaskPage], error) { @@ -58,10 +110,10 @@ func (c *Client) ReadyTasks(ctx context.Context, project string, limit int, curs var task ReadyTaskSummary err := decodeTaskFields(item, &task, []string{"task_id", "project_id", "title", "created_at"}, - []string{"task_type", "difficulty", "estimated_time_minutes", "skill_tags"}) + []string{"task_type", "difficulty", "estimated_time_minutes", "skill_tags", "compensation"}) id, validID := uuidIdentity(task.TaskID) returned, validProject := uuidIdentity(task.ProjectID) - if err != nil || !validID || !validProject || returned != selected || + if err != nil || !validID || !validProject || returned != selected || !ValidCompensation(task.Compensation) || !validTime(task.CreatedAt) || seen[id] { return result, &Failure{Code: "invalid_api_response"} } @@ -84,9 +136,9 @@ func (c *Client) ContributorTask(ctx context.Context, selector string) (Result[C var value ContributorTaskDetail err = decodeTaskFields(raw, &value, []string{"task_id", "project_id", "title", "description", "status", "created_at", "updated_at"}, - []string{"skill_tags"}) + []string{"skill_tags", "compensation"}) returned, valid := uuidIdentity(value.TaskID) - if err != nil || !valid || returned != selected || !validTask(value.TaskSummary, value.ProjectID) { + if err != nil || !valid || returned != selected || !validTask(value.TaskSummary, value.ProjectID) || !ValidCompensation(value.Compensation) { return result, &Failure{Code: "invalid_api_response"} } return Result[ContributorTaskDetail]{Raw: raw, Value: value}, nil diff --git a/cli/internal/api/task_context.go b/cli/internal/api/task_context.go index 3bc3aa46f..7e90f8bc9 100644 --- a/cli/internal/api/task_context.go +++ b/cli/internal/api/task_context.go @@ -143,8 +143,8 @@ func (c *Client) WorkContext(ctx context.Context, selector string) (Result[WorkC } if err := decodeTaskFields(fields["task"], &value.Task, []string{"task_id", "project_id", "title", "description", "status", "created_at", "updated_at"}, - []string{"skill_tags"}); err != nil || !sameUUID(value.Task.TaskID, selector) || - !validTask(value.Task.TaskSummary, value.Project.ID) { + []string{"skill_tags", "compensation"}); err != nil || !sameUUID(value.Task.TaskID, selector) || + !validTask(value.Task.TaskSummary, value.Project.ID) || !ValidCompensation(value.Task.Compensation) { return result, &Failure{Code: "invalid_api_response"} } if _, err := contextObject(fields["project"], &value.Project, []string{"id", "name", "slug"}, nil); err != nil { diff --git a/cli/internal/command/contributor_tasks.go b/cli/internal/command/contributor_tasks.go index b2c4e98b7..7fa61ff3f 100644 --- a/cli/internal/command/contributor_tasks.go +++ b/cli/internal/command/contributor_tasks.go @@ -13,7 +13,7 @@ func addContributorTasks(root *cobra.Command, client func() (*api.Client, error) var limit int var cursor string ready := &cobra.Command{ - Use: "ready PROJECT_ID", Short: "List one page of ready work under your Submitter grant", Args: cobra.ExactArgs(1), + Use: "ready PROJECT_ID", Short: "List ready work under your Submitter or Reviewer grant", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var continuation *string if cmd.Flags().Changed("cursor") { @@ -44,6 +44,9 @@ func addContributorTasks(root *cobra.Command, client func() (*api.Client, error) list(t.SkillTags), minutes, safeText(t.CreatedAt)); err != nil { return err } + if err := writeCompensation(stdout, t.Compensation); err != nil { + return err + } } _, err = fmt.Fprintf(stdout, "Next cursor: %s\n", optional(result.Value.NextCursor)) return err @@ -70,6 +73,9 @@ func addContributorTasks(root *cobra.Command, client func() (*api.Client, error) if err := writeTaskSummary(stdout, t.TaskSummary); err != nil { return err } + if err := writeCompensation(stdout, t.Compensation); err != nil { + return err + } _, err = fmt.Fprintf(stdout, "Description: %s\nAcceptance criteria: %s\nRejection criteria: %s\n", safeText(t.Description), optional(t.AcceptanceCriteria), optional(t.RejectionCriteria)) return err @@ -79,3 +85,31 @@ func addContributorTasks(root *cobra.Command, client func() (*api.Client, error) addTaskContextReads(task, client, output, stdout) root.AddCommand(task) } + +func writeCompensation(w io.Writer, terms api.CompensationTerms) error { + if _, err := fmt.Fprintf(w, "Contribution policy version: %s\n", safeText(terms.ContributionPolicyVersionID)); err != nil { + return err + } + for _, rule := range []struct { + label string + raw []byte + }{{"Accepted submission", terms.AcceptedSubmission}, {"Completed review", terms.CompletedReview}} { + awards, valid := api.CompensationAwards(rule.raw) + if !valid { + return fmt.Errorf("invalid compensation terms") + } + if awards == nil { + if _, err := fmt.Fprintf(w, "%s compensation: unpaid\n", rule.label); err != nil { + return err + } + continue + } + for _, award := range awards { + if _, err := fmt.Fprintf(w, "%s compensation: %s %s %s\n", rule.label, + safeText(award.Instrument), safeText(award.Unit), safeText(award.Quantity)); err != nil { + return err + } + } + } + return nil +} diff --git a/cli/tests/integration/contributor_task_journey.py b/cli/tests/integration/contributor_task_journey.py index 01dc7934f..b0f894637 100644 --- a/cli/tests/integration/contributor_task_journey.py +++ b/cli/tests/integration/contributor_task_journey.py @@ -184,8 +184,12 @@ async def grant(project, actor, role="submitter"): first_grant = await grant(project, actor_id) await grant(project, peer_profiles["cli-task-peer"]) await grant(project, peer_profiles["cli-task-reviewer"], role="reviewer") - for command in (("ready", project), ("show", ready_ids[0])): - read(command, peer_tokens["cli-task-reviewer"], status=404) + reviewer_ready = read(("ready", project), peer_tokens["cli-task-reviewer"]) + reviewer_detail = read(("show", ready_ids[0]), peer_tokens["cli-task-reviewer"]) + assert ( + reviewer_ready["items"][0]["compensation"] + == reviewer_detail["compensation"] + ) await context_reads(ready_ids[0], peer_tokens["cli-task-reviewer"], denied=True) for action in ("claim", "start"): await mutate( @@ -219,6 +223,7 @@ async def grant(project, actor, role="submitter"): "skill_tags", "estimated_time_minutes", "created_at", + "compensation", } observed.append(page["items"][0]["task_id"]) cursor = page["next_cursor"] diff --git a/cli/tests/integration/test_contributor_task_http.py b/cli/tests/integration/test_contributor_task_http.py index 1fa577800..791ec7827 100644 --- a/cli/tests/integration/test_contributor_task_http.py +++ b/cli/tests/integration/test_contributor_task_http.py @@ -6,6 +6,20 @@ from test_http_boundary import ACTOR, PROJECT, TOKEN, assert_failure, http_fixture from test_task_http_boundary import DETAIL, SUMMARY, SUMMARY_TEXT, TASK +POLICY = "22222222-2222-4222-8222-222222222222" +COMPENSATION = { + "contribution_policy_version_id": POLICY, + "accepted_submission": [ + { + "instrument": "money", + "unit": "USD", + "quantity": "2.125000000000000001", + }, + {"instrument": "project_points", "unit": "PTS", "quantity": "7"}, + ], + "completed_review": "unpaid", +} + READY = { key: SUMMARY[key] for key in ( @@ -18,7 +32,7 @@ "estimated_time_minutes", "created_at", ) -} +} | {"compensation": COMPENSATION} CONTRIBUTOR = { key: value for key, value in DETAIL.items() @@ -32,13 +46,21 @@ "created_by", "assigned_to", } -} | {"status": "ready"} +} | {"status": "ready", "compensation": COMPENSATION} READY_TEXT = ( f"Task: {TASK}\nProject: {PROJECT}\nTitle: Work é\\u000A\\u001B[31m\n" "Type: evaluation\nDifficulty: medium\nSkills: analysis, tag\\u000A\n" "Estimated minutes: 17\nCreated: 2026-10-01T00:00:00Z\n" + f"Contribution policy version: {POLICY}\n" + "Accepted submission compensation: money USD 2.125000000000000001\n" + "Accepted submission compensation: project_points PTS 7\n" + "Completed review compensation: unpaid\n" ) DETAIL_TEXT = SUMMARY_TEXT.replace("Status: draft", "Status: ready") + ( + f"Contribution policy version: {POLICY}\n" + "Accepted submission compensation: money USD 2.125000000000000001\n" + "Accepted submission compensation: project_points PTS 7\n" + "Completed review compensation: unpaid\n" "Description: Instructions\\u000A\\u001B[32m\n" "Acceptance criteria: Accurate\nRejection criteria: Missing\n" ) @@ -197,6 +219,39 @@ def test_contributor_reads_reject_malformed_and_management_disclosure(cli): READY | {"task_id": "bad"}, READY | {"created_at": "2026-10-01"}, READY | {"skill_tags": [None]}, + READY | {"compensation": None}, + READY | {"compensation": COMPENSATION | {"route_key": "private"}}, + READY + | { + "compensation": COMPENSATION | {"contribution_policy_version_id": "bad"} + }, + READY | {"compensation": COMPENSATION | {"accepted_submission": 2.125}}, + READY | {"compensation": COMPENSATION | {"accepted_submission": []}}, + READY + | { + "compensation": COMPENSATION + | { + "accepted_submission": [ + {"instrument": "money", "unit": "USD", "quantity": "0"} + ] + } + }, + READY + | {"compensation": COMPENSATION | {"accepted_submission": "compensated"}}, + READY + | { + "compensation": COMPENSATION + | { + "accepted_submission": [ + { + "instrument": "money", + "unit": "USD", + "quantity": "2.125", + "adapter_binding_id": ACTOR, + } + ] + } + }, ] bad_items.extend( {key: value for key, value in READY.items() if key != missing} @@ -251,6 +306,7 @@ def test_contributor_reads_reject_malformed_and_management_disclosure(cli): CONTRIBUTOR | {"project_id": "bad"}, CONTRIBUTOR | {"deadline_at": "bad"}, CONTRIBUTOR | {"skill_tags": [None]}, + CONTRIBUTOR | {"compensation": COMPENSATION | {"binding_status": "active"}}, ] required = ( "task_id", diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index 24c9993d7..20994d5b7 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -140,6 +140,12 @@ partition PROJECT nodes, checker execution, materialization, evaluation capacity output custody and routing AUTH preparation beside initial dispatch/delivery. All use the existing exact-node hash across the three lanes: placing the entire checker delivery group exclusively on project A exhausted its execution cap. +Two measured broad owners, `tests/test_projects.py` and +`tests/test_guide_document_intake.py`, instead partition their nodes only across +project A and C. Retained runs measured capacity across that pair and a project B +timeout when the modules used all three lanes. The lane logs retain the 25 slowest +pytest phases for diagnosis without letting unbounded diagnostic output consume +the execution deadline after all nodes finish. `task_lifecycle_a`, `task_lifecycle_b`, and `task_lifecycle_c` use the same deterministic partition mechanism for the remaining TASK and checker nodes. The single `schema_contracts` lane owns all baseline/PostgreSQL schema, reset and isolated-runner contracts. The diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index ee78f2399..c7a737cda 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -62,8 +62,8 @@ mutation action. | Grant | Scope | Purpose | |---|---|---| -| Submitter | exact project | Minimal project read, queue/claim/start under task guards, own submission creation/read. | -| Reviewer | exact project | Minimal project read and the planned server-selected current-work/claim/release/decision capabilities under exact review guards. | +| Submitter | exact project | Minimal project read, ready/detail discovery with locked compensation, claim/start under task guards, own submission creation/read. | +| Reviewer | exact project | Minimal project read, ready/detail discovery with both locked contribution types, and the planned server-selected current-work/claim/release/decision capabilities under exact review guards. | Contributor is the umbrella human product term. A contributor may hold independent exact-project Submitter and Reviewer grants. Celery, @@ -86,7 +86,8 @@ or the exact project; own means record-level ownership still applies. | Contribution policy and compensation-adapter binding | no | no | no | covered | no | no | no | | Project contributor grants | no | no | covered | no | read covered | no | no | | Task management | no | explicit recovery only | covered | no | read covered | no | no | -| Task queue/claim | no | operational projection only | management projection only | no | read covered | exact project under guards | no | +| Ready task queue/detail | no | no | no | no | no | exact project under guards | exact project under guards | +| Task claim/start | no | explicit recovery only | no | no | no | exact project under guards | no | | Submission create/read | no | operational projection only | management projection only | no | read covered | own assignment | read-for-review only | | Human review decision | no | no | no without reviewer grant | no | no | no | exact project under review guards | | Adjudication action | no | no | no | no | no | no | no | diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 013daeda7..6ab7bd4d8 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -105,8 +105,12 @@ No handle, allow, receipt, routing outcome or acceptance is published. Exact assignment-invalidation authority, atomic originating publication and production delivery with enforced prefork topology are implemented. Manager task create/screen/release now use exact project authority, atomic evidence and durable -replay. ARCH-03C4 adds the three exact-authorized public task queues. ARCH-03C5 -adds exact-authorized Contributor/Manager detail and requirements. ARCH-03C6 adds +replay. ARCH-03C4 adds the three exact-authorized public task queues. PILOT-14 +remains open pending human merge; its intended outcome adds locked +ContributionPolicyVersion compensation to ready rows and Contributor detail for +active exact-project Submitters or Reviewers, without Finance internals or claim +authority. ARCH-03C5 adds exact-authorized Contributor/Manager +detail and requirements. ARCH-03C6 adds separate exact-authorized locked-context reads. ARCH-03C7 adds bounded public Audit Authority history access. ARCH-03D connects hidden intake through durable intent to the activated historical guide using canonical owner ports. ARCH-04B adds hidden verified Submission input @@ -219,7 +223,7 @@ cannot be reused as post-submission review-gate evidence. See the | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation | Public activation-to-intake drill after evaluation/remediation prerequisites; prove each enabled document reader | | Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody | Public intake cutover after evaluation/remediation prerequisites | | Contribution policy administration | **Public Finance policy workflow; binding administration internal** | Internal Finance Authority adapter-binding lifecycle; public ContributionPolicy discovery/read/create/update/publish/retire with exact Finance Authority and recoverable draft selectors; immutable operation and event history; internal exact selected-version validation; CP07 binding with live exact-project manager authority of the selected published version to the active guide generation | Public intake cutover after evaluation/remediation prerequisites | -| Task readiness and claim | **Foundation with grant-backed manager and contributor commands** | Task records, assignments and locked work context; guide-bound ContributionPolicyVersion locked before `READY` and copied to TaskAssignment; detached project/guide display; public project-scoped ready, management and operational queues with distinct current grant authority and signed bounded live pagination; public contributor/management detail with exact project and assignment authority; separate current live work-context projections with exact receipt-selected review/revision/ContributionPolicy identities and no obsolete economic fields; explicit management/operational/audit locked-context projections using one historical resolver, with separate public manager, system-Operator and Audit Authority access; immutable contributor/management requirements using one historical translator, with public exact Contributor and Manager authority; bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; public manager create/screen/release use exact covered Project Manager authority, atomic audit and replay; claim/start/contributor context use exact-project Submitter grants; assigned context lists documents only and distinct task.guide.read verifies locked originals before delivery; separate manager context and system-Operator start; durable claim/start retry receipts with fresh authority and exact assignment checks; hidden exact-assignment invalidation with committed cause verification, delivery fencing, exact fixed-service authority and decision-bound immutable release evidence; atomic authority-loss publication and registered prefork delivery | Public intake cutover after evaluation/remediation prerequisites | +| Task readiness and claim | **Foundation with grant-backed manager and contributor commands** | Task records, assignments and locked work context; guide-bound ContributionPolicyVersion locked before `READY` and copied to TaskAssignment; detached project/guide display; public project-scoped ready, management and operational queues with distinct current grant authority and signed bounded live pagination; public contributor/management detail with exact project and assignment authority; separate current live work-context projections with exact receipt-selected review/revision/ContributionPolicy identities and no obsolete economic fields; explicit management/operational/audit locked-context projections using one historical resolver, with separate public manager, system-Operator and Audit Authority access; immutable contributor/management requirements using one historical translator, with public exact Contributor and Manager authority; bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; public manager create/screen/release use exact covered Project Manager authority, atomic audit and replay; claim/start/contributor context use exact-project Submitter grants; assigned context lists documents only and distinct `task.guide.read` verifies locked originals before delivery; separate manager context and system-Operator start; durable claim/start retry receipts with fresh authority and exact assignment checks; hidden exact-assignment invalidation with committed cause verification, delivery fencing, exact fixed-service authority and decision-bound immutable release evidence; atomic authority-loss publication and registered prefork delivery | PILOT-14 remains open pending human merge: add both locked contribution types to ready rows and Contributor detail for exact-project Submitters or Reviewers as unpaid or exact instrument/unit/quantity awards, without Finance binding facts. Public intake cutover follows evaluation/remediation prerequisites | | Contributor artifact preparation | **Hidden and proven** | One outer ZIP; bounded scratch inspection; canonical manifest; shared evaluation-content capacity and exact locked-policy checks before attempts or durable intent; platform and project prechecks; unchanged-work rejection; durable put intent; verification; capacity-charged ready admission; hidden final handoff validates the exact activated historical guide through owner ports | Complete the later public admission-only cutover | | Pre-submission intake checking | **Hidden with approved-guide lineage** | Separate versioned pre-submission catalogue, locked effective-plan compilation, platform/project checks during continuous preparation, blocking feedback before Submission creation, and one internal phase command covering execution/replay with the JSON precheck removed; ARCH-03D connects approved-guide lineage through the final durable handoff | Complete the canonical public cutover after evaluation/remediation prerequisites; passing intake must never substitute for post-submit evaluation | | Immutable Submission creation | **Hidden foundation; public packet creation retired** | Contributor preparation authority; durable pre-submit reservation and exact completed-evidence recovery without rerunning checks; atomic admission consumption; TASK-owned admission-backed creation with exact assignment ContributionPolicyVersion, locked policy lineage and service/database checked-packet custody; fixed-service artifact binding; replay/concurrency/rollback proof | Finish downstream evaluation and the canonical public integration. The retained submission-list GET is not a usable creation POST | @@ -333,7 +337,11 @@ cannot be reused as post-submission review-gate evidence. See the reads, with server-owned live authority and opaque continuation. [CLI contributor discovery](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-05.md) adds ready-task pages and contributor instructions through public reads, - preserving Submitter scope and assignment visibility without claiming work. + preserving exact-project contributor scope and assignment visibility without + claiming work. PILOT-14 remains open pending human merge; its intended outcome + displays the task-locked ContributionPolicyVersion UUID and both unpaid or + exact award terms in ready rows and task detail to a Submitter or Reviewer, + while claim/start remain Submitter-only. [CLI contributor claim/start](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-06.md) adds the existing public writes with explicit retry keys, server-owned assignment/lineage, fresh-authority replay and uncertain-outcome handling. @@ -887,8 +895,11 @@ remaining trace sequence is: Shared dispatch authority is delivered by AUTH-OUTBOX-02. Live assignment invalidation has atomic producer fan-out and registered delivery in ARCH-03C2. Manager readiness commands are public with exact authority in ARCH-03C3; - ARCH-03C4 exposes ready, management and Operator-status queues; - ARCH-03C5 exposes task detail and requirements; ARCH-03C6 exposes distinct + ARCH-03C4 exposes ready, management and Operator-status queues. PILOT-14 + remains open pending human merge; its intended outcome adds contributor-safe + locked compensation to ready/detail without restoring removed task payment + fields or exposing Finance bindings. ARCH-03C5 exposes task detail and + requirements; ARCH-03C6 exposes distinct locked-context reads; ARCH-03C7 exposes bounded Audit Authority history. Current authority is checked on every request. - Hidden intake: [ARCH-03D](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-03D.md) diff --git a/docs/spec_chunk_4_task_queue_assignment.md b/docs/spec_chunk_4_task_queue_assignment.md index ad349dca7..4843c3433 100644 --- a/docs/spec_chunk_4_task_queue_assignment.md +++ b/docs/spec_chunk_4_task_queue_assignment.md @@ -238,10 +238,16 @@ Both immutable detail values contain title, description, type, difficulty, tags, estimate, status, acceptance/rejection criteria, deadline and timestamps, with task/project identity. Manager detail additionally contains source type/ref/hash, import/external IDs and creator/assignee display facts. Contributor SELECTs never -load those private columns. Neither projection loads policy bodies, locked -hashes, artifacts or retired payment fields. SQL filters project/task/visibility -before returning a result; missing and invisible tasks both yield no result in -one query. Reads do not flush, commit, roll back or lock the caller's work. +load those private columns. The ready summary and Contributor detail also expose +one contributor-safe compensation block resolved from the task's exact +`locked_contribution_policy_version_id`. Each accepted-submission and +completed-review value is either `unpaid` or a list of instrument/unit/exact +decimal-string quantity awards. No adapter binding, route key, binding status, +policy lifecycle status, policy body, locked hash, artifact or retired payment +field crosses the response. SQL filters project/task/visibility before returning +a result; missing and invisible tasks both yield no result from TASK. +CONTRIBUTIONS supplies the bounded detached terms through its public locked-version +port. Reads do not flush, commit, roll back or lock the caller's work. ARCH-03B5 reuses these ports in authorized work-context responses below. ARCH-03C5 also exposes their exact standalone Contributor and Manager reads, @@ -515,6 +521,13 @@ them. Retained submission reads now use the canonical history owner below; the obsolete TASK submission get/list/evidence-lock helpers are removed. The separate internal audit evidence reader remains for its recovery consumers. +The ready queue and standalone Contributor detail accept either active +exact-project Submitter or Reviewer grant so both roles see both contribution +types before work is claimed. Reviewer authority does not authorize claim, +start, contributor work context, intake requirements or another contributor's +claimed task. Revoked, missing and foreign-project grants retain the same +concealed response. + ## Retained submission and checker history diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index c9fcf3421..6b2e16bbc 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -512,6 +512,24 @@ Submission.contribution_policy_version_id The CON participant flushes only and never commits. PROJECTS owns activation; TASK owns readiness, claim composition, and status effects. +### Contributor-visible locked terms + +Ready-task summaries and Contributor task detail resolve compensation only from +`WorkstreamTask.locked_contribution_policy_version_id`. CON exposes a bounded +public owner port for this projection; it returns the exact version UUID and +both complete contribution rules. Each rule is either the literal `unpaid` or +an ordered award list containing only `instrument`, `unit`, and the exact +decimal-string `quantity`. The projection accepts published or retired versions +so later publication cannot move terms already locked on a released task. + +TASK owns public disclosure authority. An active exact-project Submitter or +Reviewer may read both contribution types; no role-specific filtering occurs. +Missing, revoked, and foreign-project grants are concealed. The projection never +returns definition IDs, adapter binding IDs, route keys, binding lifecycle or +status, policy status, or other Finance administration facts. It does not create +an award, promise fulfillment, or change publication, locking, retirement, +payment, reconciliation, or delivery behavior. + After a human `needs_revision`, task-owned preparation locks the complete current next-attempt context. If policy changed, it validates the newly active guide-bound version through CON and atomically rebases the continuing Task and