Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .commitrail/changes/pilot14-locked-task-compensation.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions backend/app/adapters/contributions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
ContributionPolicyMutationAuthorizationPort,
ContributionPolicyReadAuthorizationPort,
ContributionPolicyValidationPort,
LockedCompensationTermsPort,
)
from app.modules.contributions.service import ContributionPolicyService
from app.modules.contributions.selected_policy_validation import (
Expand Down Expand Up @@ -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)
8 changes: 8 additions & 0 deletions backend/app/adapters/tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion backend/app/modules/authorization/artifact_project_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions backend/app/modules/authorization/domain/task_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions backend/app/modules/authorization/domain/task_queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions backend/app/modules/contributions/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@
SubmitterParticipationRequest,
SubmitterParticipationResult,
)
from app.modules.contributions.api.published_selection import (
LockedCompensationAward,
LockedCompensationTerms,
LockedCompensationTermsPort,
LockedContributionTerms,
)

__all__ = (
"ContributionPolicyOperationsPort",
Expand All @@ -62,6 +68,10 @@
"SubmitterParticipationPort",
"SubmitterParticipationRequest",
"SubmitterParticipationResult",
"LockedCompensationAward",
"LockedCompensationTerms",
"LockedCompensationTermsPort",
"LockedContributionTerms",
"CompensationMode",
"ContributionPolicyConflict",
"ContributionPolicyCreateDraftRequest",
Expand Down
59 changes: 57 additions & 2 deletions backend/app/modules/contributions/api/published_selection.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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, ...]: ...
Loading
Loading